From c1e5e0ae664bb4f736abb6339ed312f69fa748e7 Mon Sep 17 00:00:00 2001
From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
Date: Sun, 4 Jan 2026 21:18:37 +0530
Subject: [PATCH] feat(companion): new availability detail and actions pages
for ios (#26424)
* version 1
* version 1.1
* better code
* covered all edge cases
* address cubics comments
* address cubics comments
---
.../app/(tabs)/(availability)/_layout.tsx | 2 +
.../availability-detail.ios.tsx | 118 +++++
.../(availability)}/availability-detail.tsx | 0
companion/app/(tabs)/(availability)/index.tsx | 2 +-
.../(event-types)/event-type-detail.tsx | 2 -
companion/app/_layout.tsx | 104 ++++
companion/app/edit-availability-day.ios.tsx | 124 +++++
companion/app/edit-availability-day.tsx | 88 ++++
companion/app/edit-availability-hours.ios.tsx | 100 ++++
companion/app/edit-availability-hours.tsx | 57 +++
companion/app/edit-availability-name.ios.tsx | 118 +++++
companion/app/edit-availability-name.tsx | 82 +++
.../app/edit-availability-override.ios.tsx | 135 +++++
companion/app/edit-availability-override.tsx | 101 ++++
companion/bun.lock | 16 +-
.../AvailabilityListItem.android.tsx | 5 +-
.../AvailabilityListItem.ios.tsx | 5 +-
.../AvailabilityListItem.tsx | 14 +-
.../AvailabilityListItemParts.tsx | 28 +-
.../components/screens/AddGuestsScreen.tsx | 4 +-
.../screens/AvailabilityDetailScreen.ios.tsx | 483 ++++++++++++++++++
.../screens/AvailabilityDetailScreen.tsx | 76 +--
.../screens/AvailabilityListScreen.tsx | 4 +-
.../screens/EditAvailabilityDayScreen.ios.tsx | 423 +++++++++++++++
.../screens/EditAvailabilityDayScreen.tsx | 438 ++++++++++++++++
.../EditAvailabilityHoursScreen.ios.tsx | 178 +++++++
.../screens/EditAvailabilityHoursScreen.tsx | 169 ++++++
.../EditAvailabilityNameScreen.ios.tsx | 244 +++++++++
.../screens/EditAvailabilityNameScreen.tsx | 185 +++++++
.../EditAvailabilityOverrideScreen.ios.tsx | 432 ++++++++++++++++
.../EditAvailabilityOverrideScreen.tsx | 434 ++++++++++++++++
.../screens/EditLocationScreen.ios.tsx | 4 +-
.../components/screens/EditLocationScreen.tsx | 4 +-
.../screens/RescheduleScreen.android.tsx | 4 +-
.../screens/RescheduleScreen.ios.tsx | 4 +-
.../components/screens/RescheduleScreen.tsx | 4 +-
companion/constants/timezones.ts | 424 +++++++++++++++
packages/lib/timeZones.ts | 4 +
38 files changed, 4548 insertions(+), 71 deletions(-)
create mode 100644 companion/app/(tabs)/(availability)/availability-detail.ios.tsx
rename companion/app/{ => (tabs)/(availability)}/availability-detail.tsx (100%)
create mode 100644 companion/app/edit-availability-day.ios.tsx
create mode 100644 companion/app/edit-availability-day.tsx
create mode 100644 companion/app/edit-availability-hours.ios.tsx
create mode 100644 companion/app/edit-availability-hours.tsx
create mode 100644 companion/app/edit-availability-name.ios.tsx
create mode 100644 companion/app/edit-availability-name.tsx
create mode 100644 companion/app/edit-availability-override.ios.tsx
create mode 100644 companion/app/edit-availability-override.tsx
create mode 100644 companion/components/screens/AvailabilityDetailScreen.ios.tsx
create mode 100644 companion/components/screens/EditAvailabilityDayScreen.ios.tsx
create mode 100644 companion/components/screens/EditAvailabilityDayScreen.tsx
create mode 100644 companion/components/screens/EditAvailabilityHoursScreen.ios.tsx
create mode 100644 companion/components/screens/EditAvailabilityHoursScreen.tsx
create mode 100644 companion/components/screens/EditAvailabilityNameScreen.ios.tsx
create mode 100644 companion/components/screens/EditAvailabilityNameScreen.tsx
create mode 100644 companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx
create mode 100644 companion/components/screens/EditAvailabilityOverrideScreen.tsx
create mode 100644 companion/constants/timezones.ts
diff --git a/companion/app/(tabs)/(availability)/_layout.tsx b/companion/app/(tabs)/(availability)/_layout.tsx
index 186d570bd3b86b..d175a850d2c834 100644
--- a/companion/app/(tabs)/(availability)/_layout.tsx
+++ b/companion/app/(tabs)/(availability)/_layout.tsx
@@ -1,9 +1,11 @@
import { Stack } from "expo-router";
+import { Platform } from "react-native";
export default function AvailabilityLayout() {
return (
+
);
}
diff --git a/companion/app/(tabs)/(availability)/availability-detail.ios.tsx b/companion/app/(tabs)/(availability)/availability-detail.ios.tsx
new file mode 100644
index 00000000000000..40b631a3b65c01
--- /dev/null
+++ b/companion/app/(tabs)/(availability)/availability-detail.ios.tsx
@@ -0,0 +1,118 @@
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useRef } from "react";
+import {
+ AvailabilityDetailScreen,
+ type AvailabilityDetailScreenHandle,
+} from "@/components/screens/AvailabilityDetailScreen";
+
+// Type for action handlers exposed by AvailabilityDetailScreen.ios.tsx
+type ActionHandlers = {
+ handleSetAsDefault: () => void;
+ handleDelete: () => void;
+};
+
+export default function AvailabilityDetailIOS() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+
+ // Ref to store action handlers from AvailabilityDetailScreen
+ const actionHandlersRef = useRef(null);
+ const screenRef = useRef(null);
+
+ // Callback to receive action handlers from AvailabilityDetailScreen
+ const handleActionsReady = useCallback((handlers: ActionHandlers) => {
+ actionHandlersRef.current = handlers;
+ }, []);
+
+ // Navigation handlers for edit bottom sheets
+ const handleEditNameAndTimezone = useCallback(() => {
+ router.push(`/edit-availability-name?id=${id}` as never);
+ }, [router, id]);
+
+ const handleEditWorkingHours = useCallback(() => {
+ router.push(`/edit-availability-hours?id=${id}` as never);
+ }, [router, id]);
+
+ const handleEditOverride = useCallback(() => {
+ router.push(`/edit-availability-override?id=${id}` as never);
+ }, [router, id]);
+
+ // Action handlers for inline actions
+ const handleSetAsDefault = useCallback(() => {
+ if (actionHandlersRef.current?.handleSetAsDefault) {
+ actionHandlersRef.current.handleSetAsDefault();
+ } else if (screenRef.current?.setAsDefault) {
+ screenRef.current.setAsDefault();
+ }
+ }, []);
+
+ const handleDelete = useCallback(() => {
+ if (actionHandlersRef.current?.handleDelete) {
+ actionHandlersRef.current.handleDelete();
+ } else if (screenRef.current?.delete) {
+ screenRef.current.delete();
+ }
+ }, []);
+
+ if (!id) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+
+
+ {/* Edit Menu */}
+
+ Edit
+
+ {/* Name and Timezone */}
+
+ Name and Timezone
+
+
+ {/* Working Hours */}
+
+ Working Hours
+
+
+ {/* Date Override */}
+
+ Date Override
+
+
+ {/* Set as Default */}
+
+ Set as Default
+
+
+ {/* Delete */}
+
+ Delete Schedule
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/companion/app/availability-detail.tsx b/companion/app/(tabs)/(availability)/availability-detail.tsx
similarity index 100%
rename from companion/app/availability-detail.tsx
rename to companion/app/(tabs)/(availability)/availability-detail.tsx
diff --git a/companion/app/(tabs)/(availability)/index.tsx b/companion/app/(tabs)/(availability)/index.tsx
index 25c7f46ce1fb0a..7ee3dae20456b5 100644
--- a/companion/app/(tabs)/(availability)/index.tsx
+++ b/companion/app/(tabs)/(availability)/index.tsx
@@ -60,7 +60,7 @@ export default function Availability() {
onSuccess: (newSchedule) => {
// Navigate to edit the newly created schedule
router.push({
- pathname: "/availability-detail",
+ pathname: "/(tabs)/(availability)/availability-detail",
params: {
id: newSchedule.id.toString(),
},
diff --git a/companion/app/(tabs)/(event-types)/event-type-detail.tsx b/companion/app/(tabs)/(event-types)/event-type-detail.tsx
index bc7dcc6c7602a7..c3772adc151a61 100644
--- a/companion/app/(tabs)/(event-types)/event-type-detail.tsx
+++ b/companion/app/(tabs)/(event-types)/event-type-detail.tsx
@@ -13,7 +13,6 @@ import {
TouchableOpacity,
View,
} from "react-native";
-import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { AdvancedTab } from "@/components/event-type-detail/tabs/AdvancedTab";
import { AvailabilityTab } from "@/components/event-type-detail/tabs/AvailabilityTab";
@@ -127,7 +126,6 @@ export default function EventTypeDetail() {
slug?: string;
}>();
- const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState("basics");
// Form state
diff --git a/companion/app/_layout.tsx b/companion/app/_layout.tsx
index b5a7769b99a6a4..c34dbaeaabde8a 100644
--- a/companion/app/_layout.tsx
+++ b/companion/app/_layout.tsx
@@ -198,6 +198,110 @@ function RootLayoutContent() {
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
+
+
+
+
) : (
diff --git a/companion/app/edit-availability-day.ios.tsx b/companion/app/edit-availability-day.ios.tsx
new file mode 100644
index 00000000000000..67c8a645dcb895
--- /dev/null
+++ b/companion/app/edit-availability-day.ios.tsx
@@ -0,0 +1,124 @@
+import { osName } from "expo-device";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen.ios";
+import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen.ios";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+// Semi-transparent background to prevent black flash while preserving glass effect
+const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
+
+function getPresentationStyle(): "formSheet" | "modal" {
+ if (isLiquidGlassAvailable() && osName !== "iPadOS") {
+ return "formSheet";
+ }
+ return "modal";
+}
+
+export default function EditAvailabilityDayIOS() {
+ const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ const dayIndex = day ? parseInt(day, 10) : 0;
+ const dayName = DAYS[dayIndex] || "Day";
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ screenRef.current?.submit();
+ }, []);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const presentationStyle = getPresentationStyle();
+ const useGlassEffect = isLiquidGlassAvailable();
+
+ return (
+ <>
+
+
+
+
+ router.back()}>
+
+
+
+
+ {dayName}
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/companion/app/edit-availability-day.tsx b/companion/app/edit-availability-day.tsx
new file mode 100644
index 00000000000000..9fd6233da8cafc
--- /dev/null
+++ b/companion/app/edit-availability-day.tsx
@@ -0,0 +1,88 @@
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityDayScreenHandle } from "@/components/screens/EditAvailabilityDayScreen";
+import EditAvailabilityDayScreenComponent from "@/components/screens/EditAvailabilityDayScreen";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+export default function EditAvailabilityDay() {
+ const { id, day } = useLocalSearchParams<{ id: string; day: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ const dayIndex = day ? parseInt(day, 10) : 0;
+ const dayName = DAYS[dayIndex] || "Day";
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ if (isSaving) return;
+ screenRef.current?.submit();
+ }, [isSaving]);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ (
+
+ {isSaving ? "Saving..." : "Save"}
+
+ ),
+ }}
+ />
+
+
+ );
+}
diff --git a/companion/app/edit-availability-hours.ios.tsx b/companion/app/edit-availability-hours.ios.tsx
new file mode 100644
index 00000000000000..a6fbbcb62e25aa
--- /dev/null
+++ b/companion/app/edit-availability-hours.ios.tsx
@@ -0,0 +1,100 @@
+import { osName } from "expo-device";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useState } from "react";
+import { ActivityIndicator, Alert, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen.ios";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+// Semi-transparent background to prevent black flash while preserving glass effect
+const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
+
+function getPresentationStyle(): "formSheet" | "modal" {
+ if (isLiquidGlassAvailable() && osName !== "iPadOS") {
+ return "formSheet";
+ }
+ return "modal";
+}
+
+export default function EditAvailabilityHoursIOS() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleDayPress = useCallback(
+ (dayIndex: number) => {
+ router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
+ },
+ [router, id]
+ );
+
+ const presentationStyle = getPresentationStyle();
+ const useGlassEffect = isLiquidGlassAvailable();
+
+ return (
+ <>
+
+
+
+
+ router.back()}>
+
+
+
+
+ Working Hours
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/companion/app/edit-availability-hours.tsx b/companion/app/edit-availability-hours.tsx
new file mode 100644
index 00000000000000..d5814bab60256a
--- /dev/null
+++ b/companion/app/edit-availability-hours.tsx
@@ -0,0 +1,57 @@
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useState } from "react";
+import { ActivityIndicator, Alert, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import EditAvailabilityHoursScreenComponent from "@/components/screens/EditAvailabilityHoursScreen";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+export default function EditAvailabilityHours() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleDayPress = useCallback(
+ (dayIndex: number) => {
+ router.push(`/edit-availability-day?id=${id}&day=${dayIndex}` as never);
+ },
+ [router, id]
+ );
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/companion/app/edit-availability-name.ios.tsx b/companion/app/edit-availability-name.ios.tsx
new file mode 100644
index 00000000000000..dcbb39b194fc7e
--- /dev/null
+++ b/companion/app/edit-availability-name.ios.tsx
@@ -0,0 +1,118 @@
+import { osName } from "expo-device";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen.ios";
+import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen.ios";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+// Semi-transparent background to prevent black flash while preserving glass effect
+const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
+
+function getPresentationStyle(): "formSheet" | "modal" {
+ if (isLiquidGlassAvailable() && osName !== "iPadOS") {
+ return "formSheet";
+ }
+ return "modal";
+}
+
+export default function EditAvailabilityNameIOS() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ screenRef.current?.submit();
+ }, []);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const presentationStyle = getPresentationStyle();
+ const useGlassEffect = isLiquidGlassAvailable();
+
+ return (
+ <>
+
+
+
+
+ router.back()}>
+
+
+
+
+ Edit Name & Timezone
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/companion/app/edit-availability-name.tsx b/companion/app/edit-availability-name.tsx
new file mode 100644
index 00000000000000..1f994f39c8dcc0
--- /dev/null
+++ b/companion/app/edit-availability-name.tsx
@@ -0,0 +1,82 @@
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityNameScreenHandle } from "@/components/screens/EditAvailabilityNameScreen";
+import EditAvailabilityNameScreenComponent from "@/components/screens/EditAvailabilityNameScreen";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+export default function EditAvailabilityName() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ if (isSaving) return;
+ screenRef.current?.submit();
+ }, [isSaving]);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ (
+
+ {isSaving ? "Saving..." : "Save"}
+
+ ),
+ }}
+ />
+
+
+ );
+}
diff --git a/companion/app/edit-availability-override.ios.tsx b/companion/app/edit-availability-override.ios.tsx
new file mode 100644
index 00000000000000..c288131cc1a203
--- /dev/null
+++ b/companion/app/edit-availability-override.ios.tsx
@@ -0,0 +1,135 @@
+import { osName } from "expo-device";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen.ios";
+import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen.ios";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+// Semi-transparent background to prevent black flash while preserving glass effect
+const GLASS_BACKGROUND = "rgba(248, 248, 250, 0.01)";
+
+function getPresentationStyle(): "formSheet" | "modal" {
+ if (isLiquidGlassAvailable() && osName !== "iPadOS") {
+ return "formSheet";
+ }
+ return "modal";
+}
+
+export default function EditAvailabilityOverrideIOS() {
+ const { id, overrideIndex } = useLocalSearchParams<{
+ id: string;
+ overrideIndex?: string;
+ }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
+ const isEditing = editingIndex !== undefined;
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ screenRef.current?.submit();
+ }, []);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const handleEditOverride = useCallback(
+ (index: number) => {
+ // Push a new edit screen on top of the current one
+ // This allows user to go back to the override list after editing
+ router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
+ },
+ [id, router]
+ );
+
+ const presentationStyle = getPresentationStyle();
+ const useGlassEffect = isLiquidGlassAvailable();
+
+ return (
+ <>
+
+
+
+
+ router.back()}>
+
+
+
+
+ {isEditing ? "Edit Override" : "Add Override"}
+
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ >
+ );
+}
diff --git a/companion/app/edit-availability-override.tsx b/companion/app/edit-availability-override.tsx
new file mode 100644
index 00000000000000..6a079c88951219
--- /dev/null
+++ b/companion/app/edit-availability-override.tsx
@@ -0,0 +1,101 @@
+import { Stack, useLocalSearchParams, useRouter } from "expo-router";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ActivityIndicator, Alert, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { EditAvailabilityOverrideScreenHandle } from "@/components/screens/EditAvailabilityOverrideScreen";
+import EditAvailabilityOverrideScreenComponent from "@/components/screens/EditAvailabilityOverrideScreen";
+import { CalComAPIService, type Schedule } from "@/services/calcom";
+
+export default function EditAvailabilityOverride() {
+ const { id, overrideIndex } = useLocalSearchParams<{
+ id: string;
+ overrideIndex?: string;
+ }>();
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+ const [schedule, setSchedule] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const screenRef = useRef(null);
+
+ const editingIndex = overrideIndex ? parseInt(overrideIndex, 10) : undefined;
+ const isEditing = editingIndex !== undefined;
+
+ useEffect(() => {
+ if (id) {
+ setIsLoading(true);
+ CalComAPIService.getScheduleById(Number(id))
+ .then(setSchedule)
+ .catch(() => {
+ Alert.alert("Error", "Failed to load schedule details");
+ router.back();
+ })
+ .finally(() => setIsLoading(false));
+ } else {
+ setIsLoading(false);
+ Alert.alert("Error", "Schedule ID is missing");
+ router.back();
+ }
+ }, [id, router]);
+
+ const handleSave = useCallback(() => {
+ if (isSaving) return;
+ screenRef.current?.submit();
+ }, [isSaving]);
+
+ const handleSuccess = useCallback(() => {
+ router.back();
+ }, [router]);
+
+ const handleEditOverride = useCallback(
+ (index: number) => {
+ // Push a new edit screen on top of the current one
+ // This allows user to go back to the override list after editing
+ router.push(`/edit-availability-override?id=${id}&overrideIndex=${index}` as never);
+ },
+ [id, router]
+ );
+
+ const title = isEditing ? "Edit Override" : "Add Override";
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ return (
+
+ (
+
+ {isSaving ? "Saving..." : "Save"}
+
+ ),
+ }}
+ />
+
+
+ );
+}
diff --git a/companion/bun.lock b/companion/bun.lock
index 61803209ddd514..d84ea978e535e1 100644
--- a/companion/bun.lock
+++ b/companion/bun.lock
@@ -9,14 +9,14 @@
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "11.4.1",
"@react-native-segmented-control/segmented-control": "2.5.7",
- "@rn-primitives/alert-dialog": "^1.2.0",
- "@rn-primitives/dropdown-menu": "^1.2.0",
- "@rn-primitives/slot": "^1.2.0",
+ "@rn-primitives/alert-dialog": "1.2.0",
+ "@rn-primitives/dropdown-menu": "1.2.0",
+ "@rn-primitives/slot": "1.2.0",
"@tanstack/react-query": "5.62.0",
"@tanstack/react-query-persist-client": "5.62.0",
"base64-js": "1.5.1",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
"expo": "55.0.0-canary-20251230-fc48ddc",
"expo-auth-session": "7.0.11-canary-20251230-fc48ddc",
"expo-clipboard": "9.0.0-canary-20251230-fc48ddc",
@@ -33,7 +33,7 @@
"expo-splash-screen": "31.0.14-canary-20251230-fc48ddc",
"expo-status-bar": "3.0.10-canary-20251230-fc48ddc",
"expo-web-browser": "15.0.11-canary-20251230-fc48ddc",
- "lucide-react-native": "^0.562.0",
+ "lucide-react-native": "0.562.0",
"nativewind": "4.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -45,8 +45,8 @@
"react-native-svg": "15.12.1",
"react-native-web": "0.21.2",
"react-native-worklets": "0.7.1",
- "tailwind-merge": "^3.4.0",
- "tailwindcss-animate": "^1.0.7",
+ "tailwind-merge": "3.4.0",
+ "tailwindcss-animate": "1.0.7",
},
"devDependencies": {
"@biomejs/biome": "2.3.10",
diff --git a/companion/components/availability-list-item/AvailabilityListItem.android.tsx b/companion/components/availability-list-item/AvailabilityListItem.android.tsx
index 74be9a9b2f487e..155faed5250c29 100644
--- a/companion/components/availability-list-item/AvailabilityListItem.android.tsx
+++ b/companion/components/availability-list-item/AvailabilityListItem.android.tsx
@@ -89,8 +89,9 @@ export const AvailabilityListItem = ({
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
android_ripple={{ color: "rgba(0, 0, 0, 0.1)" }}
+ style={{ minWidth: 0 }}
>
-
+
@@ -102,7 +103,7 @@ export const AvailabilityListItem = ({
diff --git a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx
index 1140f587a2e32e..5e0debaefb9c98 100644
--- a/companion/components/availability-list-item/AvailabilityListItem.ios.tsx
+++ b/companion/components/availability-list-item/AvailabilityListItem.ios.tsx
@@ -71,8 +71,9 @@ export const AvailabilityListItem = ({
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
accessibilityRole="button"
+ style={{ minWidth: 0 }}
>
-
+
-
+
-
+
+
+
);
diff --git a/companion/components/availability-list-item/AvailabilityListItemParts.tsx b/companion/components/availability-list-item/AvailabilityListItemParts.tsx
index 8ce5f4edd57532..3dbfd46f7e4bad 100644
--- a/companion/components/availability-list-item/AvailabilityListItemParts.tsx
+++ b/companion/components/availability-list-item/AvailabilityListItemParts.tsx
@@ -2,6 +2,16 @@ import { Ionicons } from "@expo/vector-icons";
import { Text, TouchableOpacity, View } from "react-native";
import type { Schedule } from "@/hooks";
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${hour12}:${min} ${period}`;
+};
+
interface ScheduleNameProps {
name: string;
isDefault?: boolean;
@@ -30,18 +40,28 @@ export function AvailabilitySlots({ availability, scheduleId }: AvailabilitySlot
return No availability set;
}
+ const MAX_VISIBLE_SLOTS = 2;
+ const visibleSlots = availability.slice(0, MAX_VISIBLE_SLOTS);
+ const remainingCount = availability.length - MAX_VISIBLE_SLOTS;
+
return (
- {availability.map((slot, slotIndex) => (
+ {visibleSlots.map((slot, slotIndex) => (
0 ? "mt-2" : ""}
+ className={slotIndex > 0 ? "mt-1" : ""}
>
-
- {slot.days.join(", ")} {slot.startTime} - {slot.endTime}
+
+ {slot.days.join(", ")} {formatTime12Hour(slot.startTime)} -{" "}
+ {formatTime12Hour(slot.endTime)}
))}
+ {remainingCount > 0 && (
+
+ +{remainingCount} more {remainingCount === 1 ? "slot" : "slots"}
+
+ )}
);
}
diff --git a/companion/components/screens/AddGuestsScreen.tsx b/companion/components/screens/AddGuestsScreen.tsx
index e4d112b4b1a056..9123d17b21b625 100644
--- a/companion/components/screens/AddGuestsScreen.tsx
+++ b/companion/components/screens/AddGuestsScreen.tsx
@@ -93,7 +93,7 @@ export const AddGuestsScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
if (guests.length === 0) {
Alert.alert("Error", "Please add at least one guest");
@@ -110,7 +110,7 @@ export const AddGuestsScreen = forwardRef = {
+ Sunday: 0,
+ Monday: 1,
+ Tuesday: 2,
+ Wednesday: 3,
+ Thursday: 4,
+ Friday: 5,
+ Saturday: 6,
+};
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ const hour12Padded = hour12.toString().padStart(2, "0");
+ return `${hour12Padded}:${min} ${period}`;
+};
+
+// Format availability for display - groups days with same time range
+const formatAvailabilityDisplay = (
+ availability: Record
+): string[] => {
+ const timeRangeMap: Record = {};
+
+ Object.keys(availability).forEach((dayIndexStr) => {
+ const dayIndex = Number(dayIndexStr);
+ const slots = availability[dayIndex];
+ if (slots && slots.length > 0) {
+ slots.forEach((slot) => {
+ const timeKey = `${slot.startTime}-${slot.endTime}`;
+ if (!timeRangeMap[timeKey]) {
+ timeRangeMap[timeKey] = [];
+ }
+ timeRangeMap[timeKey].push(dayIndex);
+ });
+ }
+ });
+
+ const formatted: string[] = [];
+ Object.keys(timeRangeMap).forEach((timeKey) => {
+ const days = timeRangeMap[timeKey].sort((a, b) => a - b);
+ const [startTime, endTime] = timeKey.split("-");
+ const dayNames = days.map((day) => DAYS[day]).join(", ");
+ const timeRange = `${formatTime12Hour(startTime)} - ${formatTime12Hour(endTime)}`;
+ formatted.push(`${dayNames}, ${timeRange}`);
+ });
+
+ return formatted;
+};
+
+// Format date for display
+const formatDateForDisplay = (dateStr: string): string => {
+ if (!dateStr) return "";
+ const date = new Date(`${dateStr}T00:00:00`);
+ return date.toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+export interface AvailabilityDetailScreenProps {
+ id: string;
+ onActionsReady?: (handlers: { handleSetAsDefault: () => void; handleDelete: () => void }) => void;
+}
+
+export interface AvailabilityDetailScreenHandle {
+ setAsDefault: () => void;
+ delete: () => void;
+ refresh: () => void;
+}
+
+export const AvailabilityDetailScreen = forwardRef<
+ AvailabilityDetailScreenHandle,
+ AvailabilityDetailScreenProps
+>(function AvailabilityDetailScreen({ id, onActionsReady }, ref) {
+ const router = useRouter();
+ const insets = useSafeAreaInsets();
+
+ const [loading, setLoading] = useState(true);
+ const [_schedule, setSchedule] = useState(null);
+ const [scheduleName, setScheduleName] = useState("");
+ const [timeZone, setTimeZone] = useState("");
+ const [isDefault, setIsDefault] = useState(false);
+ const [availability, setAvailability] = useState>({});
+ const [overrides, setOverrides] = useState<
+ {
+ date: string;
+ startTime: string;
+ endTime: string;
+ }[]
+ >([]);
+ const [daysExpanded, setDaysExpanded] = useState(true);
+ const [overridesExpanded, setOverridesExpanded] = useState(true);
+
+ const processScheduleData = useCallback(
+ (scheduleData: NonNullable>>) => {
+ const name = scheduleData.name ?? "";
+ const tz = scheduleData.timeZone ?? "UTC";
+ const isDefaultSchedule = scheduleData.isDefault ?? false;
+
+ setSchedule(scheduleData);
+ setScheduleName(name);
+ setTimeZone(tz);
+ setIsDefault(isDefaultSchedule);
+
+ const availabilityMap: Record = {};
+
+ const availabilityArray = scheduleData.availability;
+ if (availabilityArray && Array.isArray(availabilityArray)) {
+ availabilityArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ days.forEach((day) => {
+ if (!availabilityMap[day]) {
+ availabilityMap[day] = [];
+ }
+ const startTime = slot.startTime ?? "09:00:00";
+ const endTime = slot.endTime ?? "17:00:00";
+ availabilityMap[day].push({
+ days: [day.toString()],
+ startTime,
+ endTime,
+ });
+ });
+ });
+ }
+
+ setAvailability(availabilityMap);
+
+ const overridesArray = scheduleData.overrides;
+ if (overridesArray && Array.isArray(overridesArray)) {
+ const formattedOverrides = overridesArray.map((override) => {
+ const date = override.date ?? "";
+ const startTime = override.startTime ?? "00:00";
+ const endTime = override.endTime ?? "00:00";
+ return { date, startTime, endTime };
+ });
+ setOverrides(formattedOverrides);
+ } else {
+ setOverrides([]);
+ }
+ },
+ []
+ );
+
+ const fetchSchedule = useCallback(async () => {
+ setLoading(true);
+ let scheduleData: Awaited> = null;
+ try {
+ scheduleData = await CalComAPIService.getScheduleById(Number(id));
+ } catch (error) {
+ console.error("Error fetching schedule");
+ if (__DEV__) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.debug("[AvailabilityDetailScreen.ios] fetchSchedule failed", {
+ message,
+ });
+ }
+ showErrorAlert("Error", "Failed to load availability. Please try again.");
+ router.back();
+ setLoading(false);
+ return;
+ }
+
+ if (scheduleData) {
+ processScheduleData(scheduleData);
+ }
+ setLoading(false);
+ }, [id, router, processScheduleData]);
+
+ useEffect(() => {
+ if (id) {
+ fetchSchedule();
+ }
+ }, [id, fetchSchedule]);
+
+ const handleSetAsDefault = useCallback(async () => {
+ if (isDefault) {
+ Alert.alert("Info", "This schedule is already set as default");
+ return;
+ }
+
+ try {
+ await CalComAPIService.updateSchedule(Number(id), {
+ isDefault: true,
+ });
+ setIsDefault(true);
+ Alert.alert("Success", "Availability set as default successfully");
+ } catch {
+ showErrorAlert("Error", "Failed to set availability as default. Please try again.");
+ }
+ }, [isDefault, id]);
+
+ const handleDelete = useCallback(() => {
+ if (isDefault) {
+ Alert.alert(
+ "Cannot Delete",
+ "You cannot delete the default schedule. Please set another schedule as default first."
+ );
+ return;
+ }
+
+ Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await CalComAPIService.deleteSchedule(Number(id));
+ Alert.alert("Success", "Availability deleted successfully", [
+ { text: "OK", onPress: () => router.back() },
+ ]);
+ } catch {
+ showErrorAlert("Error", "Failed to delete availability. Please try again.");
+ }
+ },
+ },
+ ]);
+ }, [isDefault, scheduleName, id, router]);
+
+ // Expose handlers via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ setAsDefault: handleSetAsDefault,
+ delete: handleDelete,
+ refresh: fetchSchedule,
+ }),
+ [handleSetAsDefault, handleDelete, fetchSchedule]
+ );
+
+ // Expose handlers to parent for iOS header menu
+ useEffect(() => {
+ if (onActionsReady) {
+ onActionsReady({
+ handleSetAsDefault,
+ handleDelete,
+ });
+ }
+ }, [onActionsReady, handleSetAsDefault, handleDelete]);
+
+ // Count enabled days
+ const enabledDaysCount = Object.keys(availability).length;
+
+ if (loading) {
+ return (
+
+
+ Loading availability...
+
+ );
+ }
+
+ return (
+
+
+ {/* Schedule Title Section - iOS Calendar Style */}
+
+
+
+ {scheduleName || "Untitled Schedule"}
+
+ {isDefault && (
+
+ )}
+
+
+
+ {/* Working Hours Summary Card */}
+
+
+
+ Working Hours
+
+ {Object.keys(availability).length > 0 ? (
+
+ {formatAvailabilityDisplay(availability).map((line) => (
+
+ {line}
+
+ ))}
+
+ ) : (
+ No availability set
+ )}
+
+
+
+ {/* Weekly Schedule Card - Expandable */}
+
+ setDaysExpanded(!daysExpanded)}
+ >
+ Weekly Schedule
+
+
+ {enabledDaysCount} {enabledDaysCount === 1 ? "day" : "days"}
+
+
+
+
+
+ {daysExpanded && (
+
+ {DAYS.map((day, dayIndex) => {
+ const daySlots = availability[dayIndex] || [];
+ const isEnabled = daySlots.length > 0;
+
+ return (
+ 0 ? "border-t border-[#E5E5EA]" : ""
+ }`}
+ >
+
+
+ {DAYS[dayIndex]}
+
+
+ {isEnabled ? (
+
+ {daySlots.map((slot, slotIndex) => (
+ 0 ? "mt-1" : ""}`}
+ >
+ {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
+
+ ))}
+
+ ) : (
+
+ Unavailable
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Timezone Card */}
+
+
+ Timezone
+ {timeZone}
+
+
+
+ {/* Date Overrides Card - Expandable */}
+ {overrides.length > 0 && (
+
+ setOverridesExpanded(!overridesExpanded)}
+ >
+ Date Overrides
+
+
+ {overrides.length} {overrides.length === 1 ? "override" : "overrides"}
+
+
+
+
+
+ {overridesExpanded && (
+
+ {overrides.map((override, index) => (
+ 0 ? "border-t border-[#E5E5EA]" : ""}`}
+ >
+
+ {formatDateForDisplay(override.date)}
+
+ {override.startTime === "00:00" && override.endTime === "00:00" ? (
+ Unavailable
+ ) : (
+
+ {formatTime12Hour(`${override.startTime}:00`)} –{" "}
+ {formatTime12Hour(`${override.endTime}:00`)}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* No Overrides Message */}
+ {overrides.length === 0 && (
+
+
+ Date Overrides
+ No date overrides set
+
+
+ )}
+
+
+ );
+});
diff --git a/companion/components/screens/AvailabilityDetailScreen.tsx b/companion/components/screens/AvailabilityDetailScreen.tsx
index 723e5e0f1cb59f..745d564d1edacb 100644
--- a/companion/components/screens/AvailabilityDetailScreen.tsx
+++ b/companion/components/screens/AvailabilityDetailScreen.tsx
@@ -6,6 +6,7 @@ import { ActivityIndicator, Alert, ScrollView, Switch, Text, TextInput, View } f
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { FullScreenModal } from "@/components/FullScreenModal";
+import { TIMEZONES } from "@/constants/timezones";
import { CalComAPIService, type Schedule } from "@/services/calcom";
import type { ScheduleAvailability } from "@/services/types";
import { showErrorAlert } from "@/utils/alerts";
@@ -92,6 +93,9 @@ export interface AvailabilityDetailScreenProps {
export interface AvailabilityDetailScreenHandle {
save: () => void;
+ setAsDefault: () => void;
+ delete: () => void;
+ refresh: () => void;
}
export const AvailabilityDetailScreen = forwardRef<
@@ -131,21 +135,8 @@ export const AvailabilityDetailScreen = forwardRef<
type: "start" | "end";
} | null>(null);
- // Common timezones
- const timezones = [
- "America/New_York",
- "America/Chicago",
- "America/Denver",
- "America/Los_Angeles",
- "Europe/London",
- "Europe/Paris",
- "Europe/Berlin",
- "Asia/Tokyo",
- "Asia/Shanghai",
- "Asia/Kolkata",
- "Australia/Sydney",
- "UTC",
- ];
+ // Use all supported timezones from the shared constants
+ const timezones = TIMEZONES;
const processScheduleData = useCallback(
(scheduleData: NonNullable>>) => {
@@ -237,7 +228,7 @@ export const AvailabilityDetailScreen = forwardRef<
message,
});
}
- showErrorAlert("Error", "Failed to load schedule. Please try again.");
+ showErrorAlert("Error", "Failed to load availability. Please try again.");
router.back();
setLoading(false);
return;
@@ -325,6 +316,12 @@ export const AvailabilityDetailScreen = forwardRef<
};
const handleSave = async () => {
+ // Validate availability name
+ if (!scheduleName.trim()) {
+ Alert.alert("Error", "Please enter an availability name");
+ return;
+ }
+
setSaving(true);
try {
// Convert availability object back to array format with day names
@@ -364,42 +361,37 @@ export const AvailabilityDetailScreen = forwardRef<
}));
await CalComAPIService.updateSchedule(Number(id), {
- name: scheduleName,
+ name: scheduleName.trim(),
timeZone,
availability: availabilityArray,
isDefault: isDefault,
overrides: formattedOverrides,
});
- Alert.alert("Success", "Schedule updated successfully", [
+ Alert.alert("Success", "Availability updated successfully", [
{ text: "OK", onPress: () => router.back() },
]);
setSaving(false);
} catch {
- showErrorAlert("Error", "Failed to update schedule. Please try again.");
+ showErrorAlert("Error", "Failed to update availability. Please try again.");
setSaving(false);
}
};
- // Expose save method to parent via ref
- useImperativeHandle(ref, () => ({
- save: handleSave,
- }));
-
const handleSetAsDefault = async () => {
try {
await CalComAPIService.updateSchedule(Number(id), {
isDefault: true,
});
setIsDefault(true);
- Alert.alert("Success", "Schedule set as default successfully");
+ Alert.alert("Success", "Availability set as default successfully");
} catch {
- showErrorAlert("Error", "Failed to set schedule as default. Please try again.");
+ showErrorAlert("Error", "Failed to set availability as default. Please try again.");
}
};
const handleDelete = () => {
- Alert.alert("Delete Schedule", `Are you sure you want to delete "${scheduleName}"?`, [
+ Alert.alert("Delete Availability", `Are you sure you want to delete "${scheduleName}"?`, [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
@@ -407,17 +399,25 @@ export const AvailabilityDetailScreen = forwardRef<
onPress: async () => {
try {
await CalComAPIService.deleteSchedule(Number(id));
- Alert.alert("Success", "Schedule deleted successfully", [
+ Alert.alert("Success", "Availability deleted successfully", [
{ text: "OK", onPress: () => router.back() },
]);
} catch {
- showErrorAlert("Error", "Failed to delete schedule. Please try again.");
+ showErrorAlert("Error", "Failed to delete availability. Please try again.");
}
},
},
]);
};
+ // Expose methods to parent via ref
+ useImperativeHandle(ref, () => ({
+ save: handleSave,
+ setAsDefault: handleSetAsDefault,
+ delete: handleDelete,
+ refresh: fetchSchedule,
+ }));
+
const handleAddOverride = () => {
setEditingOverride(null);
setOverrideDate("");
@@ -489,7 +489,7 @@ export const AvailabilityDetailScreen = forwardRef<
return (
- Loading schedule...
+ Loading availability...
);
}
@@ -547,9 +547,21 @@ export const AvailabilityDetailScreen = forwardRef<
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
>
- {/* Schedule Name and Working Hours Display */}
+ {/* Availability Name */}
+
+ Availability Name
+
+
+
+ {/* Working Hours Display */}
- {scheduleName}
+ Working Hours
{Object.keys(availability).length > 0 ? (
{formatAvailabilityDisplay(availability).map((line) => (
diff --git a/companion/components/screens/AvailabilityListScreen.tsx b/companion/components/screens/AvailabilityListScreen.tsx
index ab00cbc1661d5a..d81a86fb78b280 100644
--- a/companion/components/screens/AvailabilityListScreen.tsx
+++ b/companion/components/screens/AvailabilityListScreen.tsx
@@ -230,7 +230,7 @@ export function AvailabilityListScreen({
const handleSchedulePress = (schedule: Schedule) => {
router.push({
- pathname: "/availability-detail",
+ pathname: "/(tabs)/(availability)/availability-detail",
params: { id: schedule.id.toString() },
});
};
@@ -287,7 +287,7 @@ export function AvailabilityListScreen({
// Navigate to edit the newly created schedule
router.push({
- pathname: "/availability-detail",
+ pathname: "/(tabs)/(availability)/availability-detail",
params: {
id: newSchedule.id.toString(),
},
diff --git a/companion/components/screens/EditAvailabilityDayScreen.ios.tsx b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx
new file mode 100644
index 00000000000000..c5df9757de01d2
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityDayScreen.ios.tsx
@@ -0,0 +1,423 @@
+import { DatePicker, Host } from "@expo/ui/swift-ui";
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, ScrollView, Switch, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import type { ScheduleAvailability } from "@/services/types";
+import { showErrorAlert } from "@/utils/alerts";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+// Map day names to numbers
+const DAY_NAME_TO_NUMBER: Record = {
+ Sunday: 0,
+ Monday: 1,
+ Tuesday: 2,
+ Wednesday: 3,
+ Thursday: 4,
+ Friday: 5,
+ Saturday: 6,
+};
+
+// Parse time string to Date object (for DatePicker)
+const timeStringToDate = (timeStr: string): Date => {
+ const [hours, minutes] = timeStr.split(":");
+ const date = new Date();
+ date.setHours(parseInt(hours, 10));
+ date.setMinutes(parseInt(minutes || "0", 10));
+ date.setSeconds(0);
+ date.setMilliseconds(0);
+ return date;
+};
+
+// Convert Date to time string (HH:mm)
+const dateToTimeString = (date: Date): string => {
+ const hours = date.getHours().toString().padStart(2, "0");
+ const minutes = date.getMinutes().toString().padStart(2, "0");
+ return `${hours}:${minutes}`;
+};
+
+// Parse availability from schedule for a specific day
+const parseAvailabilityForDay = (
+ schedule: Schedule | null,
+ dayIndex: number
+): ScheduleAvailability[] => {
+ if (!schedule) return [];
+
+ const slots: ScheduleAvailability[] = [];
+ const availabilityArray = schedule.availability;
+
+ if (availabilityArray && Array.isArray(availabilityArray)) {
+ availabilityArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ if (days.includes(dayIndex)) {
+ const startTime = slot.startTime ?? "09:00:00";
+ const endTime = slot.endTime ?? "17:00:00";
+ slots.push({
+ days: [dayIndex.toString()],
+ startTime,
+ endTime,
+ });
+ }
+ });
+ }
+
+ return slots;
+};
+
+// Build full availability array from schedule, replacing the specific day
+const buildFullAvailability = (
+ schedule: Schedule,
+ dayIndex: number,
+ newSlots: ScheduleAvailability[]
+): { days: string[]; startTime: string; endTime: string }[] => {
+ const result: { days: string[]; startTime: string; endTime: string }[] = [];
+
+ // First, add all existing slots except for the target day
+ const existingArray = schedule.availability;
+ if (existingArray && Array.isArray(existingArray)) {
+ existingArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ // Remove the target day from this slot's days
+ const otherDays = days.filter((d) => d !== dayIndex);
+ if (otherDays.length > 0) {
+ otherDays.forEach((d) => {
+ result.push({
+ days: [DAYS[d]],
+ startTime: (slot.startTime ?? "09:00:00").substring(0, 5),
+ endTime: (slot.endTime ?? "17:00:00").substring(0, 5),
+ });
+ });
+ }
+ });
+ }
+
+ // Add the new slots for the target day
+ newSlots.forEach((slot) => {
+ result.push({
+ days: [DAYS[dayIndex]],
+ startTime: slot.startTime.substring(0, 5),
+ endTime: slot.endTime.substring(0, 5),
+ });
+ });
+
+ return result;
+};
+
+export interface EditAvailabilityDayScreenProps {
+ schedule: Schedule | null;
+ dayIndex: number;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityDayScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityDayScreen = forwardRef<
+ EditAvailabilityDayScreenHandle,
+ EditAvailabilityDayScreenProps
+>(function EditAvailabilityDayScreen(
+ { schedule, dayIndex, onSuccess, onSavingChange, transparentBackground = false },
+ ref
+) {
+ const insets = useSafeAreaInsets();
+ const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
+
+ const [isEnabled, setIsEnabled] = useState(false);
+ const [slots, setSlots] = useState<{ startTime: Date; endTime: Date }[]>([]);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const dayName = DAYS[dayIndex] || "Day";
+
+ // Initialize from schedule
+ useEffect(() => {
+ if (schedule) {
+ const daySlots = parseAvailabilityForDay(schedule, dayIndex);
+ if (daySlots.length > 0) {
+ setIsEnabled(true);
+ setSlots(
+ daySlots.map((s) => ({
+ startTime: timeStringToDate(s.startTime.substring(0, 5)),
+ endTime: timeStringToDate(s.endTime.substring(0, 5)),
+ }))
+ );
+ } else {
+ setIsEnabled(false);
+ setSlots([
+ {
+ startTime: timeStringToDate("09:00"),
+ endTime: timeStringToDate("17:00"),
+ },
+ ]);
+ }
+ }
+ }, [schedule, dayIndex]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleToggle = useCallback(
+ (value: boolean) => {
+ setIsEnabled(value);
+ if (value && slots.length === 0) {
+ setSlots([
+ {
+ startTime: timeStringToDate("09:00"),
+ endTime: timeStringToDate("17:00"),
+ },
+ ]);
+ }
+ },
+ [slots.length]
+ );
+
+ const handleAddSlot = useCallback(() => {
+ setSlots((prev) => [
+ ...prev,
+ {
+ startTime: timeStringToDate("09:00"),
+ endTime: timeStringToDate("17:00"),
+ },
+ ]);
+ }, []);
+
+ const handleRemoveSlot = useCallback((index: number) => {
+ setSlots((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const handleStartTimeChange = useCallback((index: number, date: Date) => {
+ setSlots((prev) => {
+ const newSlots = [...prev];
+ newSlots[index] = { ...newSlots[index], startTime: date };
+ return newSlots;
+ });
+ }, []);
+
+ const handleEndTimeChange = useCallback((index: number, date: Date) => {
+ setSlots((prev) => {
+ const newSlots = [...prev];
+ newSlots[index] = { ...newSlots[index], endTime: date };
+ return newSlots;
+ });
+ }, []);
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ // Validate all slots have end time after start time
+ // Compare time strings to avoid issues with Date object day components
+ if (isEnabled) {
+ for (const slot of slots) {
+ const startTimeStr = dateToTimeString(slot.startTime);
+ const endTimeStr = dateToTimeString(slot.endTime);
+ if (endTimeStr <= startTimeStr) {
+ Alert.alert("Error", "End time must be after start time for all slots");
+ return;
+ }
+ }
+ }
+
+ // Build availability slots for this day
+ const daySlots: ScheduleAvailability[] = isEnabled
+ ? slots.map((s) => ({
+ days: [dayIndex.toString()],
+ startTime: `${dateToTimeString(s.startTime)}:00`,
+ endTime: `${dateToTimeString(s.endTime)}:00`,
+ }))
+ : [];
+
+ const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots);
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ availability: fullAvailability,
+ });
+ Alert.alert("Success", `${dayName} updated successfully`, [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to update schedule. Please try again.");
+ setIsSaving(false);
+ }
+ }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]);
+
+ // Expose submit to parent via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+ {/* Enable/Disable Toggle */}
+
+ Available on {dayName}
+
+
+
+ {/* Time Slots */}
+ {isEnabled && (
+ <>
+ Time Slots
+
+ {slots.map((slot, index) => (
+
+
+ Slot {index + 1}
+ {slots.length > 1 && (
+ handleRemoveSlot(index)} className="p-1">
+
+
+ )}
+
+
+
+
+ Start
+
+
+ handleStartTimeChange(index, date)}
+ displayedComponents={["hourAndMinute"]}
+ selection={slot.startTime}
+ />
+
+
+
+
+ –
+
+
+ End
+
+
+ handleEndTimeChange(index, date)}
+ displayedComponents={["hourAndMinute"]}
+ selection={slot.endTime}
+ />
+
+
+
+
+
+ ))}
+
+ {/* Add Slot Button */}
+
+
+ Add Time Slot
+
+ >
+ )}
+
+ {/* Unavailable Message */}
+ {!isEnabled && (
+
+
+
+ You are unavailable on {dayName}
+
+
+ Toggle on to set availability
+
+
+ )}
+
+ );
+});
+
+export default EditAvailabilityDayScreen;
diff --git a/companion/components/screens/EditAvailabilityDayScreen.tsx b/companion/components/screens/EditAvailabilityDayScreen.tsx
new file mode 100644
index 00000000000000..c5c15a881a97a9
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityDayScreen.tsx
@@ -0,0 +1,438 @@
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, ScrollView, Switch, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import { FullScreenModal } from "@/components/FullScreenModal";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import type { ScheduleAvailability } from "@/services/types";
+import { showErrorAlert } from "@/utils/alerts";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+// Map day names to numbers
+const DAY_NAME_TO_NUMBER: Record = {
+ Sunday: 0,
+ Monday: 1,
+ Tuesday: 2,
+ Wednesday: 3,
+ Thursday: 4,
+ Friday: 5,
+ Saturday: 6,
+};
+
+// Generate time options (15-minute intervals)
+const generateTimeOptions = () => {
+ const options: string[] = [];
+ for (let hour = 0; hour < 24; hour++) {
+ for (let minute = 0; minute < 60; minute += 15) {
+ const h = hour.toString().padStart(2, "0");
+ const m = minute.toString().padStart(2, "0");
+ options.push(`${h}:${m}`);
+ }
+ }
+ return options;
+};
+
+const TIME_OPTIONS = generateTimeOptions();
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${hour12}:${min} ${period}`;
+};
+
+// Parse availability from schedule for a specific day
+const parseAvailabilityForDay = (
+ schedule: Schedule | null,
+ dayIndex: number
+): ScheduleAvailability[] => {
+ if (!schedule) return [];
+
+ const slots: ScheduleAvailability[] = [];
+ const availabilityArray = schedule.availability;
+
+ if (availabilityArray && Array.isArray(availabilityArray)) {
+ availabilityArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ if (days.includes(dayIndex)) {
+ const startTime = slot.startTime ?? "09:00:00";
+ const endTime = slot.endTime ?? "17:00:00";
+ slots.push({
+ days: [dayIndex.toString()],
+ startTime,
+ endTime,
+ });
+ }
+ });
+ }
+
+ return slots;
+};
+
+// Build full availability array from schedule, replacing the specific day
+const buildFullAvailability = (
+ schedule: Schedule,
+ dayIndex: number,
+ newSlots: ScheduleAvailability[]
+): { days: string[]; startTime: string; endTime: string }[] => {
+ const result: { days: string[]; startTime: string; endTime: string }[] = [];
+
+ const existingArray = schedule.availability;
+ if (existingArray && Array.isArray(existingArray)) {
+ existingArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ const otherDays = days.filter((d) => d !== dayIndex);
+ if (otherDays.length > 0) {
+ otherDays.forEach((d) => {
+ result.push({
+ days: [DAYS[d]],
+ startTime: (slot.startTime ?? "09:00:00").substring(0, 5),
+ endTime: (slot.endTime ?? "17:00:00").substring(0, 5),
+ });
+ });
+ }
+ });
+ }
+
+ newSlots.forEach((slot) => {
+ result.push({
+ days: [DAYS[dayIndex]],
+ startTime: slot.startTime.substring(0, 5),
+ endTime: slot.endTime.substring(0, 5),
+ });
+ });
+
+ return result;
+};
+
+export interface EditAvailabilityDayScreenProps {
+ schedule: Schedule | null;
+ dayIndex: number;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityDayScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityDayScreen = forwardRef<
+ EditAvailabilityDayScreenHandle,
+ EditAvailabilityDayScreenProps
+>(function EditAvailabilityDayScreen({ schedule, dayIndex, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+
+ const [isEnabled, setIsEnabled] = useState(false);
+ const [slots, setSlots] = useState<{ startTime: string; endTime: string }[]>([]);
+ const [isSaving, setIsSaving] = useState(false);
+ const [showTimePicker, setShowTimePicker] = useState<{
+ slotIndex: number;
+ type: "start" | "end";
+ } | null>(null);
+
+ const dayName = DAYS[dayIndex] || "Day";
+
+ // Initialize from schedule
+ useEffect(() => {
+ if (schedule) {
+ const daySlots = parseAvailabilityForDay(schedule, dayIndex);
+ if (daySlots.length > 0) {
+ setIsEnabled(true);
+ setSlots(
+ daySlots.map((s) => ({
+ startTime: s.startTime.substring(0, 5),
+ endTime: s.endTime.substring(0, 5),
+ }))
+ );
+ } else {
+ setIsEnabled(false);
+ setSlots([{ startTime: "09:00", endTime: "17:00" }]);
+ }
+ }
+ }, [schedule, dayIndex]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleToggle = useCallback(
+ (value: boolean) => {
+ setIsEnabled(value);
+ if (value && slots.length === 0) {
+ setSlots([{ startTime: "09:00", endTime: "17:00" }]);
+ }
+ },
+ [slots.length]
+ );
+
+ const handleAddSlot = useCallback(() => {
+ setSlots((prev) => [...prev, { startTime: "09:00", endTime: "17:00" }]);
+ }, []);
+
+ const handleRemoveSlot = useCallback((index: number) => {
+ setSlots((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
+ const handleTimeSelect = useCallback(
+ (time: string) => {
+ if (!showTimePicker) return;
+
+ setSlots((prev) => {
+ const newSlots = [...prev];
+ if (showTimePicker.type === "start") {
+ newSlots[showTimePicker.slotIndex] = {
+ ...newSlots[showTimePicker.slotIndex],
+ startTime: time,
+ };
+ } else {
+ newSlots[showTimePicker.slotIndex] = {
+ ...newSlots[showTimePicker.slotIndex],
+ endTime: time,
+ };
+ }
+ return newSlots;
+ });
+ setShowTimePicker(null);
+ },
+ [showTimePicker]
+ );
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ // Validate all slots have end time after start time
+ if (isEnabled) {
+ for (const slot of slots) {
+ if (slot.endTime <= slot.startTime) {
+ Alert.alert("Error", "End time must be after start time for all slots");
+ return;
+ }
+ }
+ }
+
+ const daySlots: ScheduleAvailability[] = isEnabled
+ ? slots.map((s) => ({
+ days: [dayIndex.toString()],
+ startTime: `${s.startTime}:00`,
+ endTime: `${s.endTime}:00`,
+ }))
+ : [];
+
+ const fullAvailability = buildFullAvailability(schedule, dayIndex, daySlots);
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ availability: fullAvailability,
+ });
+ Alert.alert("Success", `${dayName} updated successfully`, [
+ { text: "OK", onPress: onSuccess },
+ ]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to update schedule. Please try again.");
+ setIsSaving(false);
+ }
+ }, [schedule, dayIndex, dayName, isEnabled, slots, onSuccess, isSaving]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+ {/* Enable/Disable Toggle */}
+
+ Available on {dayName}
+
+
+
+ {/* Time Slots */}
+ {isEnabled && (
+ <>
+ Time Slots
+
+ {slots.map((slot, index) => (
+
+
+ Slot {index + 1}
+ {slots.length > 1 && (
+ handleRemoveSlot(index)} className="p-1">
+
+
+ )}
+
+
+
+ setShowTimePicker({ slotIndex: index, type: "start" })}
+ >
+ Start
+ {formatTime12Hour(slot.startTime)}
+
+
+ –
+
+ setShowTimePicker({ slotIndex: index, type: "end" })}
+ >
+ End
+ {formatTime12Hour(slot.endTime)}
+
+
+
+ ))}
+
+ {/* Add Slot Button */}
+
+
+ Add Time Slot
+
+ >
+ )}
+
+ {/* Unavailable Message */}
+ {!isEnabled && (
+
+
+
+ You are unavailable on {dayName}
+
+
+ Toggle on to set availability
+
+
+ )}
+
+ {/* Time Picker Modal */}
+ setShowTimePicker(null)}
+ >
+
+
+
+ Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
+
+ setShowTimePicker(null)}>
+
+
+
+
+ {TIME_OPTIONS.map((time) => {
+ const slotIndex = showTimePicker?.slotIndex ?? 0;
+ const currentTime =
+ showTimePicker?.type === "start"
+ ? slots[slotIndex]?.startTime
+ : slots[slotIndex]?.endTime;
+ const isSelected = currentTime === time;
+
+ return (
+ handleTimeSelect(time)}
+ className={`border-b border-gray-100 px-4 py-3.5 ${
+ isSelected ? "bg-blue-50" : ""
+ }`}
+ >
+
+
+ {formatTime12Hour(time)}
+
+ {isSelected && }
+
+
+ );
+ })}
+
+
+
+
+ );
+});
+
+export default EditAvailabilityDayScreen;
diff --git a/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx b/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx
new file mode 100644
index 00000000000000..6f6dde4fc7ac68
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityHoursScreen.ios.tsx
@@ -0,0 +1,178 @@
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef } from "react";
+import { ScrollView, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import type { Schedule } from "@/services/calcom";
+import type { ScheduleAvailability } from "@/services/types";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+// Map day names to numbers
+const DAY_NAME_TO_NUMBER: Record = {
+ Sunday: 0,
+ Monday: 1,
+ Tuesday: 2,
+ Wednesday: 3,
+ Thursday: 4,
+ Friday: 5,
+ Saturday: 6,
+};
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ const hour12Padded = hour12.toString().padStart(2, "0");
+ return `${hour12Padded}:${min} ${period}`;
+};
+
+// Parse availability from schedule
+const parseAvailability = (schedule: Schedule | null): Record => {
+ if (!schedule) return {};
+
+ const availabilityMap: Record = {};
+ const availabilityArray = schedule.availability;
+
+ if (availabilityArray && Array.isArray(availabilityArray)) {
+ availabilityArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ days.forEach((day) => {
+ if (!availabilityMap[day]) {
+ availabilityMap[day] = [];
+ }
+ const startTime = slot.startTime ?? "09:00:00";
+ const endTime = slot.endTime ?? "17:00:00";
+ availabilityMap[day].push({
+ days: [day.toString()],
+ startTime,
+ endTime,
+ });
+ });
+ });
+ }
+
+ return availabilityMap;
+};
+
+export interface EditAvailabilityHoursScreenProps {
+ schedule: Schedule | null;
+ onDayPress: (dayIndex: number) => void;
+ transparentBackground?: boolean;
+}
+
+export const EditAvailabilityHoursScreen = forwardRef(
+ function EditAvailabilityHoursScreen(
+ { schedule, onDayPress, transparentBackground = false },
+ _ref
+ ) {
+ const insets = useSafeAreaInsets();
+ const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
+
+ const availability = parseAvailability(schedule);
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+
+ Tap a day to edit its hours
+
+
+
+ {DAYS.map((day, dayIndex) => {
+ const daySlots = availability[dayIndex] || [];
+ const isEnabled = daySlots.length > 0;
+
+ return (
+ 0 ? "border-t border-[#E5E5EA]" : ""
+ }`}
+ onPress={() => onDayPress(dayIndex)}
+ >
+ {/* Day status indicator */}
+
+
+ {/* Day name */}
+
+ {day}
+
+
+ {/* Time slots or unavailable */}
+
+ {isEnabled ? (
+ daySlots.map((slot, slotIndex) => (
+ 0 ? "mt-0.5" : ""}`}
+ >
+ {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
+
+ ))
+ ) : (
+ Unavailable
+ )}
+
+
+ {/* Chevron */}
+
+
+ );
+ })}
+
+
+ );
+ }
+);
+
+export default EditAvailabilityHoursScreen;
diff --git a/companion/components/screens/EditAvailabilityHoursScreen.tsx b/companion/components/screens/EditAvailabilityHoursScreen.tsx
new file mode 100644
index 00000000000000..3e3d3b8f727ae1
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityHoursScreen.tsx
@@ -0,0 +1,169 @@
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef } from "react";
+import { ScrollView, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import type { Schedule } from "@/services/calcom";
+import type { ScheduleAvailability } from "@/services/types";
+
+const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
+
+// Map day names to numbers
+const DAY_NAME_TO_NUMBER: Record = {
+ Sunday: 0,
+ Monday: 1,
+ Tuesday: 2,
+ Wednesday: 3,
+ Thursday: 4,
+ Friday: 5,
+ Saturday: 6,
+};
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ const hour12Padded = hour12.toString().padStart(2, "0");
+ return `${hour12Padded}:${min} ${period}`;
+};
+
+// Parse availability from schedule
+const parseAvailability = (schedule: Schedule | null): Record => {
+ if (!schedule) return {};
+
+ const availabilityMap: Record = {};
+ const availabilityArray = schedule.availability;
+
+ if (availabilityArray && Array.isArray(availabilityArray)) {
+ availabilityArray.forEach((slot) => {
+ let days: number[] = [];
+ if (Array.isArray(slot.days)) {
+ days = slot.days
+ .map((day) => {
+ if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
+ return DAY_NAME_TO_NUMBER[day];
+ }
+ if (typeof day === "string") {
+ const parsed = parseInt(day, 10);
+ if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 6) {
+ return parsed;
+ }
+ }
+ if (typeof day === "number" && day >= 0 && day <= 6) {
+ return day;
+ }
+ return null;
+ })
+ .filter((day): day is number => day !== null);
+ }
+
+ days.forEach((day) => {
+ if (!availabilityMap[day]) {
+ availabilityMap[day] = [];
+ }
+ const startTime = slot.startTime ?? "09:00:00";
+ const endTime = slot.endTime ?? "17:00:00";
+ availabilityMap[day].push({
+ days: [day.toString()],
+ startTime,
+ endTime,
+ });
+ });
+ });
+ }
+
+ return availabilityMap;
+};
+
+export interface EditAvailabilityHoursScreenProps {
+ schedule: Schedule | null;
+ onDayPress: (dayIndex: number) => void;
+ transparentBackground?: boolean;
+}
+
+export const EditAvailabilityHoursScreen = forwardRef(
+ function EditAvailabilityHoursScreen({ schedule, onDayPress }, _ref) {
+ const insets = useSafeAreaInsets();
+
+ const availability = parseAvailability(schedule);
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+
+ Tap a day to edit its hours
+
+
+
+ {DAYS.map((day, dayIndex) => {
+ const daySlots = availability[dayIndex] || [];
+ const isEnabled = daySlots.length > 0;
+
+ return (
+ 0 ? "border-t border-gray-200" : ""
+ }`}
+ onPress={() => onDayPress(dayIndex)}
+ >
+ {/* Day status indicator */}
+
+
+ {/* Day name */}
+
+ {day}
+
+
+ {/* Time slots or unavailable */}
+
+ {isEnabled ? (
+ daySlots.map((slot, slotIndex) => (
+ 0 ? "mt-0.5" : ""}`}
+ >
+ {formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
+
+ ))
+ ) : (
+ Unavailable
+ )}
+
+
+ {/* Chevron */}
+
+
+ );
+ })}
+
+
+ );
+ }
+);
+
+export default EditAvailabilityHoursScreen;
diff --git a/companion/components/screens/EditAvailabilityNameScreen.ios.tsx b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx
new file mode 100644
index 00000000000000..d2c5992c0a7041
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityNameScreen.ios.tsx
@@ -0,0 +1,244 @@
+import { Button, ContextMenu, Host, HStack, Image } from "@expo/ui/swift-ui";
+import { buttonStyle, frame, padding } from "@expo/ui/swift-ui/modifiers";
+import { Ionicons } from "@expo/vector-icons";
+import { isLiquidGlassAvailable } from "expo-glass-effect";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
+
+// Format timezones for display
+const TIMEZONES = ALL_TIMEZONES.map((tz) => ({
+ id: tz,
+ label: tz.replace(/_/g, " "),
+}));
+
+export interface EditAvailabilityNameScreenProps {
+ schedule: Schedule | null;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityNameScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityNameScreen = forwardRef<
+ EditAvailabilityNameScreenHandle,
+ EditAvailabilityNameScreenProps
+>(function EditAvailabilityNameScreen(
+ { schedule, onSuccess, onSavingChange, transparentBackground = false },
+ ref
+) {
+ const insets = useSafeAreaInsets();
+ const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
+
+ const [name, setName] = useState("");
+ const [timezone, setTimezone] = useState("UTC");
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Initialize from schedule
+ useEffect(() => {
+ if (schedule) {
+ setName(schedule.name ?? "");
+ setTimezone(schedule.timeZone ?? "UTC");
+ }
+ }, [schedule]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleTimezoneSelect = useCallback((tz: string) => {
+ setTimezone(tz);
+ }, []);
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ Alert.alert("Error", "Please enter a schedule name");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ name: trimmedName,
+ timeZone: timezone,
+ });
+ Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to update schedule. Please try again.");
+ setIsSaving(false);
+ }
+ }, [schedule, name, timezone, onSuccess, isSaving]);
+
+ // Expose submit to parent via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ const selectedTimezoneLabel = TIMEZONES.find((tz) => tz.id === timezone)?.label || timezone;
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+
+ {transparentBackground ? (
+ <>
+ {/* Name Input - Glass UI */}
+ Schedule Name
+
+
+
+
+ {/* Timezone Selector - Glass UI */}
+ Timezone
+
+
+
+
+
+ {selectedTimezoneLabel}
+ {timezone}
+
+
+ {/* Native iOS Context Menu Button */}
+
+
+
+ {TIMEZONES.map((tz) => (
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ {/* Name Input */}
+
+ Schedule Name
+
+
+
+
+
+ {/* Timezone Selector */}
+
+ Timezone
+
+
+
+
+
+
+ {selectedTimezoneLabel}
+ {timezone}
+
+
+ {/* Native iOS Context Menu Button */}
+
+
+
+ {TIMEZONES.map((tz) => (
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+});
+
+export default EditAvailabilityNameScreen;
diff --git a/companion/components/screens/EditAvailabilityNameScreen.tsx b/companion/components/screens/EditAvailabilityNameScreen.tsx
new file mode 100644
index 00000000000000..50f11e0a025218
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityNameScreen.tsx
@@ -0,0 +1,185 @@
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, KeyboardAvoidingView, ScrollView, Text, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import { FullScreenModal } from "@/components/FullScreenModal";
+import { TIMEZONES as ALL_TIMEZONES } from "@/constants/timezones";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
+
+// Format timezones for display
+const TIMEZONES = ALL_TIMEZONES.map((tz) => ({
+ id: tz,
+ label: tz.replace(/_/g, " "),
+}));
+
+export interface EditAvailabilityNameScreenProps {
+ schedule: Schedule | null;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityNameScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityNameScreen = forwardRef<
+ EditAvailabilityNameScreenHandle,
+ EditAvailabilityNameScreenProps
+>(function EditAvailabilityNameScreen({ schedule, onSuccess, onSavingChange }, ref) {
+ const insets = useSafeAreaInsets();
+
+ const [name, setName] = useState("");
+ const [timezone, setTimezone] = useState("UTC");
+ const [isSaving, setIsSaving] = useState(false);
+ const [showTimezoneModal, setShowTimezoneModal] = useState(false);
+
+ // Initialize from schedule
+ useEffect(() => {
+ if (schedule) {
+ setName(schedule.name ?? "");
+ setTimezone(schedule.timeZone ?? "UTC");
+ }
+ }, [schedule]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ Alert.alert("Error", "Please enter a schedule name");
+ return;
+ }
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ name: trimmedName,
+ timeZone: timezone,
+ });
+ Alert.alert("Success", "Schedule updated successfully", [{ text: "OK", onPress: onSuccess }]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to update schedule. Please try again.");
+ setIsSaving(false);
+ }
+ }, [schedule, name, timezone, onSuccess, isSaving]);
+
+ // Expose submit to parent via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ const selectedTimezoneLabel = TIMEZONES.find((tz) => tz.id === timezone)?.label || timezone;
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+
+ {/* Name Input */}
+
+ Schedule Name
+
+
+
+ {/* Timezone Selector */}
+
+ Timezone
+
+ setShowTimezoneModal(true)}
+ >
+
+
+
+ {selectedTimezoneLabel}
+ {timezone}
+
+
+
+
+
+
+ {/* Timezone Modal */}
+ setShowTimezoneModal(false)}
+ >
+
+
+ Select Timezone
+ setShowTimezoneModal(false)}>
+
+
+
+
+ {TIMEZONES.map((tz) => (
+ {
+ setTimezone(tz.id);
+ setShowTimezoneModal(false);
+ }}
+ className={`border-b border-gray-100 px-4 py-3.5 ${
+ tz.id === timezone ? "bg-blue-50" : ""
+ }`}
+ >
+
+
+
+ {tz.label}
+
+ {tz.id}
+
+ {tz.id === timezone && }
+
+
+ ))}
+
+
+
+
+ );
+});
+
+export default EditAvailabilityNameScreen;
diff --git a/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx b/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx
new file mode 100644
index 00000000000000..b1eafcbab8c02e
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityOverrideScreen.ios.tsx
@@ -0,0 +1,432 @@
+import { DatePicker, Host } from "@expo/ui/swift-ui";
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, Pressable, ScrollView, Switch, Text, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${hour12}:${min} ${period}`;
+};
+
+// Parse time string to Date object (for DatePicker)
+const timeStringToDate = (timeStr: string): Date => {
+ const [hours, minutes] = timeStr.split(":");
+ const date = new Date();
+ date.setHours(parseInt(hours, 10));
+ date.setMinutes(parseInt(minutes || "0", 10));
+ date.setSeconds(0);
+ date.setMilliseconds(0);
+ return date;
+};
+
+// Convert Date to time string (HH:mm)
+const dateToTimeString = (date: Date): string => {
+ const hours = date.getHours().toString().padStart(2, "0");
+ const minutes = date.getMinutes().toString().padStart(2, "0");
+ return `${hours}:${minutes}`;
+};
+
+// Convert Date to date string (YYYY-MM-DD)
+const dateToDateString = (date: Date): string => {
+ const year = date.getFullYear();
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
+ const day = date.getDate().toString().padStart(2, "0");
+ return `${year}-${month}-${day}`;
+};
+
+// Parse date string to Date object
+const dateStringToDate = (dateStr: string): Date => {
+ const [year, month, day] = dateStr.split("-").map(Number);
+ return new Date(year, month - 1, day);
+};
+
+// Format date for display
+const formatDateForDisplay = (date: Date): string => {
+ return date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+export interface EditAvailabilityOverrideScreenProps {
+ schedule: Schedule | null;
+ overrideIndex?: number;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ onEditOverride?: (index: number) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityOverrideScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityOverrideScreen = forwardRef<
+ EditAvailabilityOverrideScreenHandle,
+ EditAvailabilityOverrideScreenProps
+>(function EditAvailabilityOverrideScreen(
+ {
+ schedule,
+ overrideIndex,
+ onSuccess,
+ onSavingChange,
+ onEditOverride,
+ transparentBackground = false,
+ },
+ ref
+) {
+ const insets = useSafeAreaInsets();
+ const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
+
+ const isEditing = overrideIndex !== undefined;
+
+ const [selectedDate, setSelectedDate] = useState(new Date());
+ const [isUnavailable, setIsUnavailable] = useState(false);
+ const [startTime, setStartTime] = useState(timeStringToDate("09:00"));
+ const [endTime, setEndTime] = useState(timeStringToDate("17:00"));
+ const [isSaving, setIsSaving] = useState(false);
+
+ // Initialize from existing override if editing
+ useEffect(() => {
+ if (schedule && isEditing && schedule.overrides) {
+ const override = schedule.overrides[overrideIndex];
+ if (override) {
+ setSelectedDate(dateStringToDate(override.date ?? ""));
+ const start = override.startTime ?? "00:00";
+ const end = override.endTime ?? "00:00";
+ setIsUnavailable(start === "00:00" && end === "00:00");
+ setStartTime(timeStringToDate(start === "00:00" ? "09:00" : start));
+ setEndTime(timeStringToDate(end === "00:00" ? "17:00" : end));
+ }
+ }
+ }, [schedule, isEditing, overrideIndex]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleDateChange = useCallback((date: Date) => {
+ setSelectedDate(date);
+ }, []);
+
+ const handleStartTimeChange = useCallback((date: Date) => {
+ setStartTime(date);
+ }, []);
+
+ const handleEndTimeChange = useCallback((date: Date) => {
+ setEndTime(date);
+ }, []);
+
+ const saveOverrides = useCallback(
+ async (
+ newOverrides: { date: string; startTime: string; endTime: string }[],
+ successMessage: string
+ ) => {
+ if (!schedule) return;
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ overrides: newOverrides,
+ });
+ Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to save override. Please try again.");
+ setIsSaving(false);
+ }
+ },
+ [schedule, onSuccess]
+ );
+
+ const handleDeleteOverride = useCallback(
+ (indexToDelete: number) => {
+ if (!schedule || !schedule.overrides) return;
+
+ const override = schedule.overrides[indexToDelete];
+ const dateDisplay = formatDateForDisplay(dateStringToDate(override?.date ?? ""));
+
+ Alert.alert(
+ "Delete Override",
+ `Are you sure you want to delete the override for ${dateDisplay}?`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => {
+ const newOverrides = schedule.overrides
+ ? schedule.overrides
+ .filter((_, idx) => idx !== indexToDelete)
+ .map((o) => ({
+ date: o.date ?? "",
+ startTime: (o.startTime ?? "00:00").substring(0, 5),
+ endTime: (o.endTime ?? "00:00").substring(0, 5),
+ }))
+ : [];
+ await saveOverrides(newOverrides, "Override deleted successfully");
+ },
+ },
+ ]
+ );
+ },
+ [schedule, saveOverrides]
+ );
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ const dateStr = dateToDateString(selectedDate);
+
+ // Validate end time is after start time (only when not marking as unavailable)
+ // Compare time strings to avoid issues with Date object day components
+ const startTimeStr = dateToTimeString(startTime);
+ const endTimeStr = dateToTimeString(endTime);
+ if (!isUnavailable && endTimeStr <= startTimeStr) {
+ Alert.alert("Error", "End time must be after start time");
+ return;
+ }
+
+ // Build the new override
+ const newOverride = {
+ date: dateStr,
+ startTime: isUnavailable ? "00:00" : dateToTimeString(startTime),
+ endTime: isUnavailable ? "00:00" : dateToTimeString(endTime),
+ };
+
+ // Build new overrides array
+ let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
+
+ if (schedule.overrides && Array.isArray(schedule.overrides)) {
+ newOverrides = schedule.overrides.map((o) => ({
+ date: o.date ?? "",
+ startTime: (o.startTime ?? "00:00").substring(0, 5),
+ endTime: (o.endTime ?? "00:00").substring(0, 5),
+ }));
+ }
+
+ const successMessage = isEditing
+ ? "Override updated successfully"
+ : "Override added successfully";
+
+ if (isEditing && overrideIndex !== undefined) {
+ // Update existing override
+ newOverrides[overrideIndex] = newOverride;
+ } else {
+ // Check if date already exists
+ const existingIndex = newOverrides.findIndex((o) => o.date === dateStr);
+ if (existingIndex >= 0) {
+ Alert.alert(
+ "Date Already Exists",
+ "An override for this date already exists. Do you want to replace it?",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Replace",
+ onPress: async () => {
+ newOverrides[existingIndex] = newOverride;
+ await saveOverrides(newOverrides, "Override replaced successfully");
+ },
+ },
+ ]
+ );
+ return;
+ }
+ // Add new override
+ newOverrides.push(newOverride);
+ }
+
+ await saveOverrides(newOverrides, successMessage);
+ }, [
+ schedule,
+ selectedDate,
+ isUnavailable,
+ startTime,
+ endTime,
+ isEditing,
+ overrideIndex,
+ saveOverrides,
+ isSaving,
+ ]);
+
+ // Expose submit to parent via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+ {/* Date Picker */}
+ Date
+
+
+
+
+
+
+ {formatDateForDisplay(selectedDate)}
+
+
+
+
+
+
+
+
+
+ {/* Unavailable Toggle */}
+
+
+
+
+
+
+ Mark as unavailable
+ Block this entire day
+
+
+
+
+
+ {/* Time Range (only when available) */}
+ {!isUnavailable && (
+ <>
+ Available Hours
+
+
+
+ Start Time
+
+
+
+
+
+ –
+
+
+ End Time
+
+
+
+
+
+
+ >
+ )}
+
+ {/* Existing Overrides (if any) */}
+ {!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
+ <>
+
+ Existing Overrides
+
+
+ {schedule.overrides.map((override, index) => (
+ 0 ? "border-t border-[#E5E5EA]" : ""
+ }`}
+ onPress={() => onEditOverride?.(index)}
+ >
+
+
+ {formatDateForDisplay(dateStringToDate(override.date ?? ""))}
+
+ {override.startTime === "00:00" && override.endTime === "00:00" ? (
+ Unavailable
+ ) : (
+
+ {formatTime12Hour(override.startTime ?? "00:00")} –{" "}
+ {formatTime12Hour(override.endTime ?? "00:00")}
+
+ )}
+
+
+ handleDeleteOverride(index)}
+ >
+
+
+ onEditOverride?.(index)}
+ >
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ );
+});
+
+export default EditAvailabilityOverrideScreen;
diff --git a/companion/components/screens/EditAvailabilityOverrideScreen.tsx b/companion/components/screens/EditAvailabilityOverrideScreen.tsx
new file mode 100644
index 00000000000000..33b167755b3f2c
--- /dev/null
+++ b/companion/components/screens/EditAvailabilityOverrideScreen.tsx
@@ -0,0 +1,434 @@
+import { Ionicons } from "@expo/vector-icons";
+import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
+import { Alert, ScrollView, Switch, Text, TextInput, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { AppPressable } from "@/components/AppPressable";
+import { FullScreenModal } from "@/components/FullScreenModal";
+import type { Schedule } from "@/services/calcom";
+import { CalComAPIService } from "@/services/calcom";
+import { showErrorAlert } from "@/utils/alerts";
+
+// Generate time options (15-minute intervals)
+const generateTimeOptions = () => {
+ const options: string[] = [];
+ for (let hour = 0; hour < 24; hour++) {
+ for (let minute = 0; minute < 60; minute += 15) {
+ const h = hour.toString().padStart(2, "0");
+ const m = minute.toString().padStart(2, "0");
+ options.push(`${h}:${m}`);
+ }
+ }
+ return options;
+};
+
+const TIME_OPTIONS = generateTimeOptions();
+
+// Convert 24-hour time to 12-hour format with AM/PM
+const formatTime12Hour = (time24: string): string => {
+ const [hours, minutes] = time24.split(":");
+ const hour = parseInt(hours, 10);
+ const min = minutes || "00";
+ const period = hour >= 12 ? "PM" : "AM";
+ const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
+ return `${hour12}:${min} ${period}`;
+};
+
+// Format date for display
+const formatDateForDisplay = (dateStr: string): string => {
+ if (!dateStr) return "";
+ const date = new Date(`${dateStr}T00:00:00`);
+ return date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+};
+
+export interface EditAvailabilityOverrideScreenProps {
+ schedule: Schedule | null;
+ overrideIndex?: number;
+ onSuccess: () => void;
+ onSavingChange?: (isSaving: boolean) => void;
+ onEditOverride?: (index: number) => void;
+ transparentBackground?: boolean;
+}
+
+export interface EditAvailabilityOverrideScreenHandle {
+ submit: () => void;
+}
+
+export const EditAvailabilityOverrideScreen = forwardRef<
+ EditAvailabilityOverrideScreenHandle,
+ EditAvailabilityOverrideScreenProps
+>(function EditAvailabilityOverrideScreen(
+ { schedule, overrideIndex, onSuccess, onSavingChange, onEditOverride },
+ ref
+) {
+ const insets = useSafeAreaInsets();
+
+ const isEditing = overrideIndex !== undefined;
+
+ const [selectedDate, setSelectedDate] = useState("");
+ const [isUnavailable, setIsUnavailable] = useState(false);
+ const [startTime, setStartTime] = useState("09:00");
+ const [endTime, setEndTime] = useState("17:00");
+ const [isSaving, setIsSaving] = useState(false);
+ const [showTimePicker, setShowTimePicker] = useState<{
+ type: "start" | "end";
+ } | null>(null);
+
+ // Initialize from existing override if editing
+ useEffect(() => {
+ if (schedule && isEditing && schedule.overrides && overrideIndex !== undefined) {
+ const override = schedule.overrides[overrideIndex];
+ if (override) {
+ setSelectedDate(override.date ?? "");
+ const start = override.startTime ?? "00:00";
+ const end = override.endTime ?? "00:00";
+ setIsUnavailable(start === "00:00" && end === "00:00");
+ setStartTime(start === "00:00" ? "09:00" : start);
+ setEndTime(end === "00:00" ? "17:00" : end);
+ }
+ }
+ }, [schedule, isEditing, overrideIndex]);
+
+ // Notify parent of saving state
+ useEffect(() => {
+ onSavingChange?.(isSaving);
+ }, [isSaving, onSavingChange]);
+
+ const handleTimeSelect = useCallback(
+ (time: string) => {
+ if (!showTimePicker) return;
+
+ if (showTimePicker.type === "start") {
+ setStartTime(time);
+ } else {
+ setEndTime(time);
+ }
+ setShowTimePicker(null);
+ },
+ [showTimePicker]
+ );
+
+ const saveOverrides = useCallback(
+ async (
+ newOverrides: { date: string; startTime: string; endTime: string }[],
+ successMessage: string
+ ) => {
+ if (!schedule) return;
+
+ setIsSaving(true);
+ try {
+ await CalComAPIService.updateSchedule(schedule.id, {
+ overrides: newOverrides,
+ });
+ Alert.alert("Success", successMessage, [{ text: "OK", onPress: onSuccess }]);
+ setIsSaving(false);
+ } catch {
+ showErrorAlert("Error", "Failed to save override. Please try again.");
+ setIsSaving(false);
+ }
+ },
+ [schedule, onSuccess]
+ );
+
+ const handleDeleteOverride = useCallback(
+ (indexToDelete: number) => {
+ if (!schedule || !schedule.overrides) return;
+
+ const override = schedule.overrides[indexToDelete];
+ const dateDisplay = formatDateForDisplay(override?.date ?? "");
+
+ Alert.alert(
+ "Delete Override",
+ `Are you sure you want to delete the override for ${dateDisplay}?`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Delete",
+ style: "destructive",
+ onPress: async () => {
+ const newOverrides = schedule.overrides
+ ? schedule.overrides
+ .filter((_, idx) => idx !== indexToDelete)
+ .map((o) => ({
+ date: o.date ?? "",
+ startTime: (o.startTime ?? "00:00").substring(0, 5),
+ endTime: (o.endTime ?? "00:00").substring(0, 5),
+ }))
+ : [];
+ await saveOverrides(newOverrides, "Override deleted successfully");
+ },
+ },
+ ]
+ );
+ },
+ [schedule, saveOverrides]
+ );
+
+ const handleSubmit = useCallback(async () => {
+ if (!schedule || isSaving) return;
+
+ if (!selectedDate) {
+ Alert.alert("Error", "Please enter a date (YYYY-MM-DD format)");
+ return;
+ }
+
+ // Validate date format
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
+ if (!dateRegex.test(selectedDate)) {
+ Alert.alert("Error", "Please enter date in YYYY-MM-DD format");
+ return;
+ }
+
+ // Validate end time is after start time (only when not marking as unavailable)
+ if (!isUnavailable && endTime <= startTime) {
+ Alert.alert("Error", "End time must be after start time");
+ return;
+ }
+
+ const newOverride = {
+ date: selectedDate,
+ startTime: isUnavailable ? "00:00" : startTime,
+ endTime: isUnavailable ? "00:00" : endTime,
+ };
+
+ let newOverrides: { date: string; startTime: string; endTime: string }[] = [];
+
+ if (schedule.overrides && Array.isArray(schedule.overrides)) {
+ newOverrides = schedule.overrides.map((o) => ({
+ date: o.date ?? "",
+ startTime: (o.startTime ?? "00:00").substring(0, 5),
+ endTime: (o.endTime ?? "00:00").substring(0, 5),
+ }));
+ }
+
+ const successMessage = isEditing
+ ? "Override updated successfully"
+ : "Override added successfully";
+
+ if (isEditing && overrideIndex !== undefined) {
+ newOverrides[overrideIndex] = newOverride;
+ } else {
+ const existingIndex = newOverrides.findIndex((o) => o.date === selectedDate);
+ if (existingIndex >= 0) {
+ Alert.alert(
+ "Date Already Exists",
+ "An override for this date already exists. Do you want to replace it?",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Replace",
+ onPress: async () => {
+ newOverrides[existingIndex] = newOverride;
+ await saveOverrides(newOverrides, "Override replaced successfully");
+ },
+ },
+ ]
+ );
+ return;
+ }
+ newOverrides.push(newOverride);
+ }
+
+ await saveOverrides(newOverrides, successMessage);
+ }, [
+ schedule,
+ selectedDate,
+ isUnavailable,
+ startTime,
+ endTime,
+ isEditing,
+ overrideIndex,
+ saveOverrides,
+ isSaving,
+ ]);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ submit: handleSubmit,
+ }),
+ [handleSubmit]
+ );
+
+ if (!schedule) {
+ return (
+
+ No schedule data
+
+ );
+ }
+
+ return (
+
+ {/* Date Input */}
+ Date (YYYY-MM-DD)
+
+
+
+
+
+ {selectedDate && (
+
+ {formatDateForDisplay(selectedDate)}
+
+ )}
+
+
+ {/* Unavailable Toggle */}
+
+
+
+
+ Mark as unavailable
+ Block this entire day
+
+
+
+
+
+ {/* Time Range (only when available) */}
+ {!isUnavailable && (
+ <>
+ Available Hours
+
+
+ setShowTimePicker({ type: "start" })}
+ >
+ Start Time
+ {formatTime12Hour(startTime)}
+
+
+ –
+
+ setShowTimePicker({ type: "end" })}
+ >
+ End Time
+ {formatTime12Hour(endTime)}
+
+
+
+ >
+ )}
+
+ {/* Existing Overrides */}
+ {!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
+ <>
+
+ Existing Overrides
+
+
+ {schedule.overrides.map((override, index) => (
+ 0 ? "border-t border-gray-200" : ""
+ }`}
+ onPress={() => onEditOverride?.(index)}
+ >
+
+
+ {formatDateForDisplay(override.date ?? "")}
+
+ {override.startTime === "00:00" && override.endTime === "00:00" ? (
+ Unavailable
+ ) : (
+
+ {formatTime12Hour(override.startTime ?? "00:00")} –{" "}
+ {formatTime12Hour(override.endTime ?? "00:00")}
+
+ )}
+
+
+ handleDeleteOverride(index)}
+ >
+
+
+ onEditOverride?.(index)}
+ >
+
+
+
+
+ ))}
+
+ >
+ )}
+
+ {/* Time Picker Modal */}
+ setShowTimePicker(null)}
+ >
+
+
+
+ Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
+
+ setShowTimePicker(null)}>
+
+
+
+
+ {TIME_OPTIONS.map((time) => {
+ const currentTime = showTimePicker?.type === "start" ? startTime : endTime;
+ const isSelected = currentTime === time;
+
+ return (
+ handleTimeSelect(time)}
+ className={`border-b border-gray-100 px-4 py-3.5 ${
+ isSelected ? "bg-blue-50" : ""
+ }`}
+ >
+
+
+ {formatTime12Hour(time)}
+
+ {isSelected && }
+
+
+ );
+ })}
+
+
+
+
+ );
+});
+
+export default EditAvailabilityOverrideScreen;
diff --git a/companion/components/screens/EditLocationScreen.ios.tsx b/companion/components/screens/EditLocationScreen.ios.tsx
index eac46c72ca2003..fe88b212238857 100644
--- a/companion/components/screens/EditLocationScreen.ios.tsx
+++ b/companion/components/screens/EditLocationScreen.ios.tsx
@@ -125,7 +125,7 @@ export const EditLocationScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -171,7 +171,7 @@ export const EditLocationScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -176,7 +176,7 @@ export const EditLocationScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
Alert.alert("Error", "Please select a future date and time");
@@ -92,7 +92,7 @@ export const RescheduleScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
if (selectedDateTime <= new Date()) {
Alert.alert("Error", "Please select a future date and time");
@@ -75,7 +75,7 @@ export const RescheduleScreen = forwardRef {
diff --git a/companion/components/screens/RescheduleScreen.tsx b/companion/components/screens/RescheduleScreen.tsx
index bdb2a68fec655a..4e9bf5af4cc900 100644
--- a/companion/components/screens/RescheduleScreen.tsx
+++ b/companion/components/screens/RescheduleScreen.tsx
@@ -70,7 +70,7 @@ export const RescheduleScreen = forwardRef {
- if (!booking) return;
+ if (!booking || isSaving) return;
// Validate the date is in the future
if (selectedDateTime <= new Date()) {
@@ -98,7 +98,7 @@ export const RescheduleScreen = forwardRef {
diff --git a/companion/constants/timezones.ts b/companion/constants/timezones.ts
new file mode 100644
index 00000000000000..970c2cad34425c
--- /dev/null
+++ b/companion/constants/timezones.ts
@@ -0,0 +1,424 @@
+// Timezone constants for the companion app
+// Source: cal.com/packages/lib/timeZones.ts
+
+export const TIMEZONES = [
+ "Africa/Abidjan",
+ "Africa/Accra",
+ "Africa/Addis_Ababa",
+ "Africa/Algiers",
+ "Africa/Asmera",
+ "Africa/Bamako",
+ "Africa/Bangui",
+ "Africa/Banjul",
+ "Africa/Bissau",
+ "Africa/Blantyre",
+ "Africa/Brazzaville",
+ "Africa/Bujumbura",
+ "Africa/Cairo",
+ "Africa/Casablanca",
+ "Africa/Ceuta",
+ "Africa/Conakry",
+ "Africa/Dakar",
+ "Africa/Dar_es_Salaam",
+ "Africa/Djibouti",
+ "Africa/Douala",
+ "Africa/El_Aaiun",
+ "Africa/Freetown",
+ "Africa/Gaborone",
+ "Africa/Harare",
+ "Africa/Johannesburg",
+ "Africa/Juba",
+ "Africa/Kampala",
+ "Africa/Khartoum",
+ "Africa/Kigali",
+ "Africa/Kinshasa",
+ "Africa/Lagos",
+ "Africa/Libreville",
+ "Africa/Lome",
+ "Africa/Luanda",
+ "Africa/Lubumbashi",
+ "Africa/Lusaka",
+ "Africa/Malabo",
+ "Africa/Maputo",
+ "Africa/Maseru",
+ "Africa/Mbabane",
+ "Africa/Mogadishu",
+ "Africa/Monrovia",
+ "Africa/Nairobi",
+ "Africa/Ndjamena",
+ "Africa/Niamey",
+ "Africa/Nouakchott",
+ "Africa/Ouagadougou",
+ "Africa/Porto-Novo",
+ "Africa/Sao_Tome",
+ "Africa/Tripoli",
+ "Africa/Tunis",
+ "Africa/Windhoek",
+ "America/Adak",
+ "America/Anchorage",
+ "America/Anguilla",
+ "America/Antigua",
+ "America/Araguaina",
+ "America/Argentina/La_Rioja",
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Salta",
+ "America/Argentina/San_Juan",
+ "America/Argentina/San_Luis",
+ "America/Argentina/Tucuman",
+ "America/Argentina/Ushuaia",
+ "America/Aruba",
+ "America/Asuncion",
+ "America/Bahia",
+ "America/Bahia_Banderas",
+ "America/Barbados",
+ "America/Belem",
+ "America/Belize",
+ "America/Blanc-Sablon",
+ "America/Boa_Vista",
+ "America/Bogota",
+ "America/Boise",
+ "America/Buenos_Aires",
+ "America/Cambridge_Bay",
+ "America/Campo_Grande",
+ "America/Cancun",
+ "America/Caracas",
+ "America/Catamarca",
+ "America/Cayenne",
+ "America/Cayman",
+ "America/Chicago",
+ "America/Chihuahua",
+ "America/Ciudad_Juarez",
+ "America/Coral_Harbour",
+ "America/Cordoba",
+ "America/Costa_Rica",
+ "America/Creston",
+ "America/Cuiaba",
+ "America/Curacao",
+ "America/Danmarkshavn",
+ "America/Dawson",
+ "America/Dawson_Creek",
+ "America/Denver",
+ "America/Detroit",
+ "America/Dominica",
+ "America/Edmonton",
+ "America/Eirunepe",
+ "America/El_Salvador",
+ "America/Fort_Nelson",
+ "America/Fortaleza",
+ "America/Glace_Bay",
+ "America/Godthab",
+ "America/Goose_Bay",
+ "America/Grand_Turk",
+ "America/Grenada",
+ "America/Guadeloupe",
+ "America/Guatemala",
+ "America/Guayaquil",
+ "America/Guyana",
+ "America/Halifax",
+ "America/Havana",
+ "America/Hermosillo",
+ "America/Indiana/Knox",
+ "America/Indiana/Marengo",
+ "America/Indiana/Petersburg",
+ "America/Indiana/Tell_City",
+ "America/Indiana/Vevay",
+ "America/Indiana/Vincennes",
+ "America/Indiana/Winamac",
+ "America/Indianapolis",
+ "America/Inuvik",
+ "America/Iqaluit",
+ "America/Jamaica",
+ "America/Jujuy",
+ "America/Juneau",
+ "America/Kentucky/Monticello",
+ "America/Kralendijk",
+ "America/La_Paz",
+ "America/Lima",
+ "America/Los_Angeles",
+ "America/Louisville",
+ "America/Lower_Princes",
+ "America/Maceio",
+ "America/Managua",
+ "America/Manaus",
+ "America/Marigot",
+ "America/Martinique",
+ "America/Matamoros",
+ "America/Mazatlan",
+ "America/Mendoza",
+ "America/Menominee",
+ "America/Merida",
+ "America/Metlakatla",
+ "America/Mexico_City",
+ "America/Miquelon",
+ "America/Moncton",
+ "America/Monterrey",
+ "America/Montevideo",
+ "America/Montserrat",
+ "America/Nassau",
+ "America/New_York",
+ "America/Nome",
+ "America/Noronha",
+ "America/North_Dakota/Beulah",
+ "America/North_Dakota/Center",
+ "America/North_Dakota/New_Salem",
+ "America/Ojinaga",
+ "America/Panama",
+ "America/Paramaribo",
+ "America/Phoenix",
+ "America/Port-au-Prince",
+ "America/Port_of_Spain",
+ "America/Porto_Velho",
+ "America/Puerto_Rico",
+ "America/Punta_Arenas",
+ "America/Rankin_Inlet",
+ "America/Recife",
+ "America/Regina",
+ "America/Resolute",
+ "America/Rio_Branco",
+ "America/Santarem",
+ "America/Santiago",
+ "America/Santo_Domingo",
+ "America/Sao_Paulo",
+ "America/Scoresbysund",
+ "America/Sitka",
+ "America/St_Barthelemy",
+ "America/St_Johns",
+ "America/St_Kitts",
+ "America/St_Lucia",
+ "America/St_Thomas",
+ "America/St_Vincent",
+ "America/Swift_Current",
+ "America/Tegucigalpa",
+ "America/Thule",
+ "America/Tijuana",
+ "America/Toronto",
+ "America/Tortola",
+ "America/Vancouver",
+ "America/Whitehorse",
+ "America/Winnipeg",
+ "America/Yakutat",
+ "Antarctica/Casey",
+ "Antarctica/Davis",
+ "Antarctica/DumontDUrville",
+ "Antarctica/Macquarie",
+ "Antarctica/Mawson",
+ "Antarctica/McMurdo",
+ "Antarctica/Palmer",
+ "Antarctica/Rothera",
+ "Antarctica/Syowa",
+ "Antarctica/Troll",
+ "Antarctica/Vostok",
+ "Arctic/Longyearbyen",
+ "Asia/Aden",
+ "Asia/Almaty",
+ "Asia/Amman",
+ "Asia/Anadyr",
+ "Asia/Aqtau",
+ "Asia/Aqtobe",
+ "Asia/Ashgabat",
+ "Asia/Atyrau",
+ "Asia/Baghdad",
+ "Asia/Bahrain",
+ "Asia/Baku",
+ "Asia/Bangkok",
+ "Asia/Barnaul",
+ "Asia/Beirut",
+ "Asia/Bishkek",
+ "Asia/Brunei",
+ "Asia/Chita",
+ "Asia/Colombo",
+ "Asia/Damascus",
+ "Asia/Dhaka",
+ "Asia/Dili",
+ "Asia/Dubai",
+ "Asia/Dushanbe",
+ "Asia/Famagusta",
+ "Asia/Gaza",
+ "Asia/Hebron",
+ "Asia/Hong_Kong",
+ "Asia/Hovd",
+ "Asia/Irkutsk",
+ "Asia/Jakarta",
+ "Asia/Jayapura",
+ "Asia/Jerusalem",
+ "Asia/Kabul",
+ "Asia/Kamchatka",
+ "Asia/Karachi",
+ "Asia/Katmandu",
+ "Asia/Kolkata",
+ "Asia/Khandyga",
+ "Asia/Krasnoyarsk",
+ "Asia/Kuala_Lumpur",
+ "Asia/Kuching",
+ "Asia/Kuwait",
+ "Asia/Macau",
+ "Asia/Magadan",
+ "Asia/Makassar",
+ "Asia/Manila",
+ "Asia/Muscat",
+ "Asia/Nicosia",
+ "Asia/Novokuznetsk",
+ "Asia/Novosibirsk",
+ "Asia/Omsk",
+ "Asia/Oral",
+ "Asia/Phnom_Penh",
+ "Asia/Pontianak",
+ "Asia/Pyongyang",
+ "Asia/Qatar",
+ "Asia/Qostanay",
+ "Asia/Qyzylorda",
+ "Asia/Rangoon",
+ "Asia/Riyadh",
+ "Asia/Saigon",
+ "Asia/Sakhalin",
+ "Asia/Samarkand",
+ "Asia/Seoul",
+ "Asia/Shanghai",
+ "Asia/Singapore",
+ "Asia/Srednekolymsk",
+ "Asia/Taipei",
+ "Asia/Tashkent",
+ "Asia/Tbilisi",
+ "Asia/Tehran",
+ "Asia/Thimphu",
+ "Asia/Tokyo",
+ "Asia/Tomsk",
+ "Asia/Ulaanbaatar",
+ "Asia/Urumqi",
+ "Asia/Ust-Nera",
+ "Asia/Vientiane",
+ "Asia/Vladivostok",
+ "Asia/Yakutsk",
+ "Asia/Yekaterinburg",
+ "Asia/Yerevan",
+ "Atlantic/Azores",
+ "Atlantic/Bermuda",
+ "Atlantic/Canary",
+ "Atlantic/Cape_Verde",
+ "Atlantic/Faeroe",
+ "Atlantic/Madeira",
+ "Atlantic/Reykjavik",
+ "Atlantic/South_Georgia",
+ "Atlantic/St_Helena",
+ "Atlantic/Stanley",
+ "Australia/Adelaide",
+ "Australia/Brisbane",
+ "Australia/Broken_Hill",
+ "Australia/Darwin",
+ "Australia/Eucla",
+ "Australia/Hobart",
+ "Australia/Lindeman",
+ "Australia/Lord_Howe",
+ "Australia/Melbourne",
+ "Australia/Perth",
+ "Australia/Sydney",
+ "Europe/Amsterdam",
+ "Europe/Andorra",
+ "Europe/Astrakhan",
+ "Europe/Athens",
+ "Europe/Belgrade",
+ "Europe/Berlin",
+ "Europe/Bratislava",
+ "Europe/Brussels",
+ "Europe/Bucharest",
+ "Europe/Budapest",
+ "Europe/Busingen",
+ "Europe/Chisinau",
+ "Europe/Copenhagen",
+ "Europe/Dublin",
+ "Europe/Gibraltar",
+ "Europe/Guernsey",
+ "Europe/Helsinki",
+ "Europe/Isle_of_Man",
+ "Europe/Istanbul",
+ "Europe/Jersey",
+ "Europe/Kaliningrad",
+ "Europe/Kiev",
+ "Europe/Kirov",
+ "Europe/Lisbon",
+ "Europe/Ljubljana",
+ "Europe/London",
+ "Europe/Luxembourg",
+ "Europe/Madrid",
+ "Europe/Malta",
+ "Europe/Mariehamn",
+ "Europe/Minsk",
+ "Europe/Monaco",
+ "Europe/Moscow",
+ "Europe/Oslo",
+ "Europe/Paris",
+ "Europe/Podgorica",
+ "Europe/Prague",
+ "Europe/Riga",
+ "Europe/Rome",
+ "Europe/Samara",
+ "Europe/San_Marino",
+ "Europe/Sarajevo",
+ "Europe/Saratov",
+ "Europe/Simferopol",
+ "Europe/Skopje",
+ "Europe/Sofia",
+ "Europe/Stockholm",
+ "Europe/Tallinn",
+ "Europe/Tirane",
+ "Europe/Ulyanovsk",
+ "Europe/Vaduz",
+ "Europe/Vatican",
+ "Europe/Vienna",
+ "Europe/Vilnius",
+ "Europe/Volgograd",
+ "Europe/Warsaw",
+ "Europe/Zagreb",
+ "Europe/Zurich",
+ "Indian/Antananarivo",
+ "Indian/Chagos",
+ "Indian/Christmas",
+ "Indian/Cocos",
+ "Indian/Comoro",
+ "Indian/Kerguelen",
+ "Indian/Mahe",
+ "Indian/Maldives",
+ "Indian/Mauritius",
+ "Indian/Mayotte",
+ "Indian/Reunion",
+ "Pacific/Apia",
+ "Pacific/Auckland",
+ "Pacific/Bougainville",
+ "Pacific/Chatham",
+ "Pacific/Easter",
+ "Pacific/Efate",
+ "Pacific/Enderbury",
+ "Pacific/Fakaofo",
+ "Pacific/Fiji",
+ "Pacific/Funafuti",
+ "Pacific/Galapagos",
+ "Pacific/Gambier",
+ "Pacific/Guadalcanal",
+ "Pacific/Guam",
+ "Pacific/Honolulu",
+ "Pacific/Kiritimati",
+ "Pacific/Kosrae",
+ "Pacific/Kwajalein",
+ "Pacific/Majuro",
+ "Pacific/Marquesas",
+ "Pacific/Midway",
+ "Pacific/Nauru",
+ "Pacific/Niue",
+ "Pacific/Norfolk",
+ "Pacific/Noumea",
+ "Pacific/Pago_Pago",
+ "Pacific/Palau",
+ "Pacific/Pitcairn",
+ "Pacific/Ponape",
+ "Pacific/Port_Moresby",
+ "Pacific/Rarotonga",
+ "Pacific/Saipan",
+ "Pacific/Tahiti",
+ "Pacific/Tarawa",
+ "Pacific/Tongatapu",
+ "Pacific/Truk",
+ "Pacific/Wake",
+ "Pacific/Wallis",
+] as const;
+
+export type Timezone = (typeof TIMEZONES)[number];
diff --git a/packages/lib/timeZones.ts b/packages/lib/timeZones.ts
index b23176e1f51a40..ce9fcceb4bc79e 100644
--- a/packages/lib/timeZones.ts
+++ b/packages/lib/timeZones.ts
@@ -1,3 +1,7 @@
+/**
+ * IMPORTANT: If you update this timezone array, please also update the TIMEZONES array
+ * in companion/constants/timezones.ts to keep the companion apps in sync.
+ */
export const IntlSupportedTimeZones = [
"Africa/Abidjan",
"Africa/Accra",