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
This commit is contained in:
Dhairyashil Shinde
2026-01-04 12:48:37 -03:00
committed by GitHub
parent f9dc3f2f9c
commit c1e5e0ae66
38 changed files with 4548 additions and 71 deletions
@@ -1,9 +1,11 @@
import { Stack } from "expo-router";
import { Platform } from "react-native";
export default function AvailabilityLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="availability-detail" options={{ headerShown: Platform.OS === "ios" }} />
</Stack>
);
}
@@ -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<ActionHandlers | null>(null);
const screenRef = useRef<AvailabilityDetailScreenHandle>(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 (
<>
<Stack.Screen
options={{
title: "Availability",
headerBackTitle: "Availability",
headerBackButtonDisplayMode: "default",
headerTitle: "",
headerStyle: {
backgroundColor: "#f2f2f7",
},
headerShadowVisible: false,
}}
/>
<Stack.Header style={{ shadowColor: "transparent", backgroundColor: "#f2f2f7" }}>
<Stack.Header.Right>
{/* Edit Menu */}
<Stack.Header.Menu>
<Stack.Header.Label>Edit</Stack.Header.Label>
{/* Name and Timezone */}
<Stack.Header.MenuAction icon="pencil" onPress={handleEditNameAndTimezone}>
Name and Timezone
</Stack.Header.MenuAction>
{/* Working Hours */}
<Stack.Header.MenuAction icon="clock" onPress={handleEditWorkingHours}>
Working Hours
</Stack.Header.MenuAction>
{/* Date Override */}
<Stack.Header.MenuAction icon="calendar.badge.plus" onPress={handleEditOverride}>
Date Override
</Stack.Header.MenuAction>
{/* Set as Default */}
<Stack.Header.MenuAction icon="star" onPress={handleSetAsDefault}>
Set as Default
</Stack.Header.MenuAction>
{/* Delete */}
<Stack.Header.MenuAction icon="trash" onPress={handleDelete} destructive>
Delete Schedule
</Stack.Header.MenuAction>
</Stack.Header.Menu>
</Stack.Header.Right>
</Stack.Header>
<AvailabilityDetailScreen
ref={screenRef}
id={id}
// @ts-expect-error - onActionsReady is only available in AvailabilityDetailScreen.ios.tsx
onActionsReady={handleActionsReady}
/>
</>
);
}
@@ -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(),
},
@@ -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
+104
View File
@@ -198,6 +198,110 @@ function RootLayoutContent() {
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
<Stack.Screen
name="edit-availability-name"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Edit Name & Timezone",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.5, 0.7] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
},
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
<Stack.Screen
name="edit-availability-hours"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Working Hours",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 1] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
},
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
<Stack.Screen
name="edit-availability-day"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Edit Day",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.6, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
},
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
<Stack.Screen
name="edit-availability-override"
options={{
headerShown: true,
headerTransparent: Platform.OS === "ios",
headerLargeTitle: false,
title: "Date Override",
presentation:
Platform.OS === "ios"
? isLiquidGlassAvailable()
? "formSheet"
: "modal"
: "containedModal",
sheetGrabberVisible: Platform.OS === "ios",
sheetAllowedDetents: Platform.OS === "ios" ? [0.7, 0.9] : undefined,
sheetInitialDetentIndex: Platform.OS === "ios" ? 0 : undefined,
contentStyle: {
backgroundColor:
Platform.OS === "ios" && isLiquidGlassAvailable() ? "transparent" : "#F2F2F7",
},
headerStyle: {
backgroundColor: Platform.OS === "ios" ? "transparent" : "#F2F2F7",
},
headerBlurEffect: Platform.OS === "ios" && isLiquidGlassAvailable() ? undefined : "light",
}}
/>
</Stack>
) : (
<LoginScreenComponent />
+124
View File
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityDayScreenHandle>(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 (
<>
<Stack.Screen
options={{
title: dayName,
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.6, 0.9],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>{dayName}</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor="#000"
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityDayScreenComponent
ref={screenRef}
schedule={schedule}
dayIndex={dayIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
+88
View File
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityDayScreenHandle>(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 (
<View
className="flex-1 items-center justify-center bg-white"
style={{ paddingBottom: insets.bottom }}
>
<Stack.Screen options={{ title: dayName }} />
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
<Stack.Screen
options={{
title: dayName,
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
),
}}
/>
<EditAvailabilityDayScreenComponent
ref={screenRef}
schedule={schedule}
dayIndex={dayIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
/>
</View>
);
}
@@ -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<Schedule | null>(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 (
<>
<Stack.Screen
options={{
title: "Working Hours",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Working Hours</Stack.Header.Title>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityHoursScreenComponent
schedule={schedule}
onDayPress={handleDayPress}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
+57
View File
@@ -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<Schedule | null>(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 (
<View
className="flex-1 items-center justify-center bg-white"
style={{ paddingBottom: insets.bottom }}
>
<Stack.Screen options={{ title: "Working Hours" }} />
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
<Stack.Screen options={{ title: "Working Hours" }} />
<EditAvailabilityHoursScreenComponent schedule={schedule} onDayPress={handleDayPress} />
</View>
);
}
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityNameScreenHandle>(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 (
<>
<Stack.Screen
options={{
title: "Edit Name & Timezone",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>Edit Name & Timezone</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor="#000"
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityNameScreenComponent
ref={screenRef}
schedule={schedule}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
+82
View File
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityNameScreenHandle>(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 (
<View
className="flex-1 items-center justify-center bg-white"
style={{ paddingBottom: insets.bottom }}
>
<Stack.Screen options={{ title: "Edit Name & Timezone" }} />
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
<Stack.Screen
options={{
title: "Edit Name & Timezone",
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
),
}}
/>
<EditAvailabilityNameScreenComponent
ref={screenRef}
schedule={schedule}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
/>
</View>
);
}
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(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 (
<>
<Stack.Screen
options={{
title: isEditing ? "Edit Override" : "Add Override",
presentation: presentationStyle,
sheetGrabberVisible: true,
sheetAllowedDetents: [0.7, 1],
sheetInitialDetentIndex: 0,
contentStyle: {
backgroundColor: useGlassEffect ? GLASS_BACKGROUND : "#F2F2F7",
},
}}
/>
<Stack.Header>
<Stack.Header.Left>
<Stack.Header.Button onPress={() => router.back()}>
<Stack.Header.Icon sf="xmark" />
</Stack.Header.Button>
</Stack.Header.Left>
<Stack.Header.Title>{isEditing ? "Edit Override" : "Add Override"}</Stack.Header.Title>
<Stack.Header.Right>
<Stack.Header.Button
onPress={handleSave}
disabled={isSaving}
variant="prominent"
tintColor="#000"
>
<Stack.Header.Icon sf="checkmark" />
</Stack.Header.Button>
</Stack.Header.Right>
</Stack.Header>
<View
style={{
flex: 1,
paddingTop: 56,
paddingBottom: insets.bottom,
}}
>
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="#007AFF" />
</View>
) : (
<EditAvailabilityOverrideScreenComponent
ref={screenRef}
schedule={schedule}
overrideIndex={editingIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
onEditOverride={handleEditOverride}
transparentBackground={useGlassEffect}
/>
)}
</View>
</>
);
}
@@ -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<Schedule | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const screenRef = useRef<EditAvailabilityOverrideScreenHandle>(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 (
<View
className="flex-1 items-center justify-center bg-white"
style={{ paddingBottom: insets.bottom }}
>
<Stack.Screen options={{ title }} />
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
return (
<View className="flex-1 bg-white" style={{ paddingBottom: insets.bottom }}>
<Stack.Screen
options={{
title,
headerRight: () => (
<Text
onPress={handleSave}
className={`text-[17px] font-semibold ${
isSaving ? "text-gray-400" : "text-[#007AFF]"
}`}
>
{isSaving ? "Saving..." : "Save"}
</Text>
),
}}
/>
<EditAvailabilityOverrideScreenComponent
ref={screenRef}
schedule={schedule}
overrideIndex={editingIndex}
onSuccess={handleSuccess}
onSavingChange={setIsSaving}
onEditOverride={handleEditOverride}
/>
</View>
);
}
+8 -8
View File
@@ -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",
@@ -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 }}
>
<View>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
@@ -102,7 +103,7 @@ export const AvailabilityListItem = ({
<DropdownMenuTrigger asChild>
<Pressable
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
style={{ width: 32, height: 32, flexShrink: 0 }}
>
<Ionicons name="ellipsis-horizontal" size={18} color="#3C3F44" />
</Pressable>
@@ -71,8 +71,9 @@ export const AvailabilityListItem = ({
onPress={() => handleSchedulePress(schedule)}
className="mr-4 flex-1"
accessibilityRole="button"
style={{ minWidth: 0 }}
>
<View>
<View style={{ flex: 1 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots
availability={schedule.availability}
@@ -85,7 +86,7 @@ export const AvailabilityListItem = ({
{/* Three dots menu - fixed hit target so it doesn't get squeezed off-screen */}
<View
className="items-center justify-center rounded-lg border border-cal-border"
style={{ width: 32, height: 32 }}
style={{ width: 32, height: 32, flexShrink: 0 }}
>
<Host matchContents>
<ContextMenu
@@ -34,16 +34,18 @@ export const AvailabilityListItem = ({
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
>
<View className="flex-row items-center justify-between">
<View className="mr-4 flex-1">
<View className="mr-4 flex-1" style={{ minWidth: 0 }}>
<ScheduleName name={schedule.name} isDefault={schedule.isDefault} />
<AvailabilitySlots availability={schedule.availability} scheduleId={schedule.id} />
<TimeZoneRow timeZone={schedule.timeZone} />
</View>
<ScheduleActionsButton
schedule={schedule}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
/>
<View style={{ flexShrink: 0 }}>
<ScheduleActionsButton
schedule={schedule}
setSelectedSchedule={setSelectedSchedule}
setShowActionsModal={setShowActionsModal}
/>
</View>
</View>
</TouchableOpacity>
);
@@ -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 <Text className="text-sm text-cal-text-secondary">No availability set</Text>;
}
const MAX_VISIBLE_SLOTS = 2;
const visibleSlots = availability.slice(0, MAX_VISIBLE_SLOTS);
const remainingCount = availability.length - MAX_VISIBLE_SLOTS;
return (
<View>
{availability.map((slot, slotIndex) => (
{visibleSlots.map((slot, slotIndex) => (
<View
key={`${scheduleId}-${slot.days.join("-")}-${slotIndex}`}
className={slotIndex > 0 ? "mt-2" : ""}
className={slotIndex > 0 ? "mt-1" : ""}
>
<Text className="text-sm text-cal-text-secondary">
{slot.days.join(", ")} {slot.startTime} - {slot.endTime}
<Text className="text-sm text-cal-text-secondary" numberOfLines={1}>
{slot.days.join(", ")} {formatTime12Hour(slot.startTime)} -{" "}
{formatTime12Hour(slot.endTime)}
</Text>
</View>
))}
{remainingCount > 0 && (
<Text className="mt-1 text-sm text-cal-text-secondary">
+{remainingCount} more {remainingCount === 1 ? "slot" : "slots"}
</Text>
)}
</View>
);
}
@@ -93,7 +93,7 @@ export const AddGuestsScreen = forwardRef<AddGuestsScreenHandle, AddGuestsScreen
);
const handleSubmit = useCallback(async () => {
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<AddGuestsScreenHandle, AddGuestsScreen
Alert.alert("Error", "Failed to add guests. Please try again.");
setIsSaving(false);
}
}, [booking, guests, onSuccess]);
}, [booking, guests, onSuccess, isSaving]);
// Expose submit function to parent via ref (same pattern as senior's actionHandlersRef)
useImperativeHandle(
@@ -0,0 +1,483 @@
/**
* AvailabilityDetailScreen Component (iOS)
*
* iOS-specific read-only display of availability schedule.
* Editing is done via separate bottom sheet screens.
*/
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useState } from "react";
import { ActivityIndicator, Alert, ScrollView, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { AppPressable } from "@/components/AppPressable";
import { CalComAPIService, type Schedule } 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<string, number> = {
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<number, ScheduleAvailability[]>
): string[] => {
const timeRangeMap: Record<string, number[]> = {};
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<Schedule | null>(null);
const [scheduleName, setScheduleName] = useState("");
const [timeZone, setTimeZone] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [availability, setAvailability] = useState<Record<number, ScheduleAvailability[]>>({});
const [overrides, setOverrides] = useState<
{
date: string;
startTime: string;
endTime: string;
}[]
>([]);
const [daysExpanded, setDaysExpanded] = useState(true);
const [overridesExpanded, setOverridesExpanded] = useState(true);
const processScheduleData = useCallback(
(scheduleData: NonNullable<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
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<number, ScheduleAvailability[]> = {};
const availabilityArray = scheduleData.availability;
if (availabilityArray && Array.isArray(availabilityArray)) {
availabilityArray.forEach((slot) => {
let days: number[] = [];
if (Array.isArray(slot.days)) {
days = slot.days
.map((day) => {
if (typeof day === "string" && DAY_NAME_TO_NUMBER[day] !== undefined) {
return DAY_NAME_TO_NUMBER[day];
}
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<ReturnType<typeof CalComAPIService.getScheduleById>> = null;
try {
scheduleData = await CalComAPIService.getScheduleById(Number(id));
} catch (error) {
console.error("Error fetching schedule");
if (__DEV__) {
const message = error instanceof Error ? error.message : String(error);
console.debug("[AvailabilityDetailScreen.ios] fetchSchedule failed", {
message,
});
}
showErrorAlert("Error", "Failed to load availability. Please try again.");
router.back();
setLoading(false);
return;
}
if (scheduleData) {
processScheduleData(scheduleData);
}
setLoading(false);
}, [id, router, processScheduleData]);
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 (
<View className="flex-1 items-center justify-center bg-[#f2f2f7]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-[15px] text-[#8E8E93]">Loading availability...</Text>
</View>
);
}
return (
<View className="flex-1 bg-[#f2f2f7]">
<ScrollView
className="flex-1"
contentContainerStyle={{
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: insets.bottom + 100,
}}
showsVerticalScrollIndicator={false}
>
{/* Schedule Title Section - iOS Calendar Style */}
<View className="mb-4">
<View className="mb-2 flex-row flex-wrap items-center">
<Text
className="text-[26px] font-semibold leading-tight text-black"
style={{ letterSpacing: -0.3 }}
>
{scheduleName || "Untitled Schedule"}
</Text>
{isDefault && (
<Ionicons
name="star-outline"
size={20}
color="#000000"
style={{ marginLeft: 6, marginTop: 2 }}
/>
)}
</View>
</View>
{/* Working Hours Summary Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="mb-2.5 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Working Hours
</Text>
{Object.keys(availability).length > 0 ? (
<View>
{formatAvailabilityDisplay(availability).map((line) => (
<Text key={line} className="mb-1 text-[17px] text-black">
{line}
</Text>
))}
</View>
) : (
<Text className="text-[17px] text-[#8E8E93]">No availability set</Text>
)}
</View>
</View>
{/* Weekly Schedule Card - Expandable */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<AppPressable
className="flex-row items-center justify-between px-4 py-3.5"
onPress={() => setDaysExpanded(!daysExpanded)}
>
<Text className="text-[17px] text-black">Weekly Schedule</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">
{enabledDaysCount} {enabledDaysCount === 1 ? "day" : "days"}
</Text>
<Ionicons
name={daysExpanded ? "chevron-down" : "chevron-forward"}
size={18}
color="#C7C7CC"
/>
</View>
</AppPressable>
{daysExpanded && (
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
{DAYS.map((day, dayIndex) => {
const daySlots = availability[dayIndex] || [];
const isEnabled = daySlots.length > 0;
return (
<View
key={day}
className={`flex-row items-center py-3 ${
dayIndex > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
>
<View
className={`mr-3 h-2.5 w-2.5 rounded-full ${
isEnabled ? "bg-[#34C759]" : "bg-[#E5E5EA]"
}`}
/>
<Text
className={`w-24 text-[15px] font-medium ${
isEnabled ? "text-black" : "text-[#8E8E93]"
}`}
>
{DAYS[dayIndex]}
</Text>
{isEnabled ? (
<View className="flex-1 items-end">
{daySlots.map((slot, slotIndex) => (
<Text
key={`${slotIndex}-${slot.startTime}`}
className={`text-[15px] text-black ${slotIndex > 0 ? "mt-1" : ""}`}
>
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
</Text>
))}
</View>
) : (
<Text className="flex-1 text-right text-[15px] text-[#8E8E93]">
Unavailable
</Text>
)}
</View>
);
})}
</View>
)}
</View>
{/* Timezone Card */}
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="flex-row items-center justify-between px-4 py-3.5">
<Text className="text-[17px] text-black">Timezone</Text>
<Text className="text-[17px] text-[#8E8E93]">{timeZone}</Text>
</View>
</View>
{/* Date Overrides Card - Expandable */}
{overrides.length > 0 && (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<AppPressable
className="flex-row items-center justify-between px-4 py-3.5"
onPress={() => setOverridesExpanded(!overridesExpanded)}
>
<Text className="text-[17px] text-black">Date Overrides</Text>
<View className="flex-row items-center">
<Text className="mr-1 text-[17px] text-[#8E8E93]">
{overrides.length} {overrides.length === 1 ? "override" : "overrides"}
</Text>
<Ionicons
name={overridesExpanded ? "chevron-down" : "chevron-forward"}
size={18}
color="#C7C7CC"
/>
</View>
</AppPressable>
{overridesExpanded && (
<View className="border-t border-[#E5E5EA] px-4 py-2.5">
{overrides.map((override, index) => (
<View
key={override.date}
className={`py-2.5 ${index > 0 ? "border-t border-[#E5E5EA]" : ""}`}
>
<Text className="mb-0.5 text-[17px] text-black">
{formatDateForDisplay(override.date)}
</Text>
{override.startTime === "00:00" && override.endTime === "00:00" ? (
<Text className="text-[15px] text-[#FF3B30]">Unavailable</Text>
) : (
<Text className="text-[15px] text-[#8E8E93]">
{formatTime12Hour(`${override.startTime}:00`)} –{" "}
{formatTime12Hour(`${override.endTime}:00`)}
</Text>
)}
</View>
))}
</View>
)}
</View>
)}
{/* No Overrides Message */}
{overrides.length === 0 && (
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<View className="px-4 py-3.5">
<Text className="text-[17px] text-black">Date Overrides</Text>
<Text className="mt-1 text-[15px] text-[#8E8E93]">No date overrides set</Text>
</View>
</View>
)}
</ScrollView>
</View>
);
});
@@ -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<Awaited<ReturnType<typeof CalComAPIService.getScheduleById>>>) => {
@@ -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 (
<View className="flex-1 items-center justify-center bg-[#f8f9fa]">
<ActivityIndicator size="large" color="#000000" />
<Text className="mt-4 text-base text-[#666]">Loading schedule...</Text>
<Text className="mt-4 text-base text-[#666]">Loading availability...</Text>
</View>
);
}
@@ -547,9 +547,21 @@ export const AvailabilityDetailScreen = forwardRef<
contentContainerStyle={{ padding: 16, paddingBottom: 200 }}
>
<View className="gap-4">
{/* Schedule Name and Working Hours Display */}
{/* Availability Name */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">{scheduleName}</Text>
<Text className="mb-3 text-base font-semibold text-[#333]">Availability Name</Text>
<TextInput
value={scheduleName}
onChangeText={setScheduleName}
placeholder="Enter availability name"
placeholderTextColor="#999"
className="rounded-lg border border-[#E5E5EA] bg-[#F8F9FA] px-3 py-3 text-base text-[#333]"
/>
</View>
{/* Working Hours Display */}
<View className="rounded-2xl bg-white p-6">
<Text className="mb-3 text-xl font-bold text-[#333]">Working Hours</Text>
{Object.keys(availability).length > 0 ? (
<View>
{formatAvailabilityDisplay(availability).map((line) => (
@@ -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(),
},
@@ -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<string, number> = {
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 (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className={`flex-1 ${backgroundStyle}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={!transparentBackground}
>
{/* Enable/Disable Toggle */}
<View
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<Text className="text-[17px] font-medium text-black">Available on {dayName}</Text>
<Switch
value={isEnabled}
onValueChange={handleToggle}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#fff"
/>
</View>
{/* Time Slots */}
{isEnabled && (
<>
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Time Slots</Text>
{slots.map((slot, index) => (
<View
key={`slot-${slot.startTime}-${slot.endTime}-${index}`}
className={`mb-3 rounded-xl px-4 py-3 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center justify-between">
<Text className="text-[15px] font-medium text-[#8E8E93]">Slot {index + 1}</Text>
{slots.length > 1 && (
<AppPressable onPress={() => handleRemoveSlot(index)} className="p-1">
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</AppPressable>
)}
</View>
<View className="mt-3 flex-row items-center">
<View className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">Start</Text>
<View className="flex-row items-center">
<Host matchContents>
<DatePicker
onDateChange={(date) => handleStartTimeChange(index, date)}
displayedComponents={["hourAndMinute"]}
selection={slot.startTime}
/>
</Host>
</View>
</View>
<Text className="mx-3 text-[17px] text-[#8E8E93]">–</Text>
<View className="flex-1">
<Text className="mb-1 text-[13px] text-[#8E8E93]">End</Text>
<View className="flex-row items-center">
<Host matchContents>
<DatePicker
onDateChange={(date) => handleEndTimeChange(index, date)}
displayedComponents={["hourAndMinute"]}
selection={slot.endTime}
/>
</Host>
</View>
</View>
</View>
</View>
))}
{/* Add Slot Button */}
<AppPressable
onPress={handleAddSlot}
className={`flex-row items-center justify-center rounded-xl py-3.5 ${
transparentBackground
? "border border-dashed border-gray-300/60 bg-white/40"
: "border border-dashed border-[#C7C7CC] bg-white"
}`}
>
<Ionicons name="add-circle" size={20} color="#007AFF" />
<Text className="ml-2 text-[15px] font-medium text-[#007AFF]">Add Time Slot</Text>
</AppPressable>
</>
)}
{/* Unavailable Message */}
{!isEnabled && (
<View
className={`items-center rounded-xl px-4 py-8 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<Ionicons name="moon-outline" size={40} color="#8E8E93" />
<Text className="mt-3 text-center text-[17px] text-[#8E8E93]">
You are unavailable on {dayName}
</Text>
<Text className="mt-1 text-center text-[15px] text-[#C7C7CC]">
Toggle on to set availability
</Text>
</View>
)}
</ScrollView>
);
});
export default EditAvailabilityDayScreen;
@@ -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<string, number> = {
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 (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-gray-500">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-white"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
>
{/* Enable/Disable Toggle */}
<View className="mb-4 flex-row items-center justify-between rounded-lg border border-gray-200 px-4 py-3.5">
<Text className="text-[17px] font-medium text-black">Available on {dayName}</Text>
<Switch
value={isEnabled}
onValueChange={handleToggle}
trackColor={{ false: "#E5E5EA", true: "#34C759" }}
thumbColor="#fff"
/>
</View>
{/* Time Slots */}
{isEnabled && (
<>
<Text className="mb-2 text-[13px] font-medium text-gray-500">Time Slots</Text>
{slots.map((slot, index) => (
<View
key={`slot-${slot.startTime}-${slot.endTime}-${index}`}
className="mb-3 rounded-lg border border-gray-200 px-4 py-3"
>
<View className="flex-row items-center justify-between">
<Text className="text-[15px] font-medium text-gray-500">Slot {index + 1}</Text>
{slots.length > 1 && (
<AppPressable onPress={() => handleRemoveSlot(index)} className="p-1">
<Ionicons name="trash-outline" size={20} color="#FF3B30" />
</AppPressable>
)}
</View>
<View className="mt-3 flex-row items-center">
<AppPressable
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
onPress={() => setShowTimePicker({ slotIndex: index, type: "start" })}
>
<Text className="text-[13px] text-gray-500">Start</Text>
<Text className="text-[17px] text-black">{formatTime12Hour(slot.startTime)}</Text>
</AppPressable>
<Text className="mx-3 text-[17px] text-gray-400">–</Text>
<AppPressable
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
onPress={() => setShowTimePicker({ slotIndex: index, type: "end" })}
>
<Text className="text-[13px] text-gray-500">End</Text>
<Text className="text-[17px] text-black">{formatTime12Hour(slot.endTime)}</Text>
</AppPressable>
</View>
</View>
))}
{/* Add Slot Button */}
<AppPressable
onPress={handleAddSlot}
className="flex-row items-center justify-center rounded-lg border border-dashed border-gray-300 py-3.5"
>
<Ionicons name="add-circle" size={20} color="#007AFF" />
<Text className="ml-2 text-[15px] font-medium text-[#007AFF]">Add Time Slot</Text>
</AppPressable>
</>
)}
{/* Unavailable Message */}
{!isEnabled && (
<View className="items-center rounded-lg border border-gray-200 px-4 py-8">
<Ionicons name="moon-outline" size={40} color="#8E8E93" />
<Text className="mt-3 text-center text-[17px] text-gray-500">
You are unavailable on {dayName}
</Text>
<Text className="mt-1 text-center text-[15px] text-gray-400">
Toggle on to set availability
</Text>
</View>
)}
{/* Time Picker Modal */}
<FullScreenModal
visible={!!showTimePicker}
animationType="slide"
onRequestClose={() => setShowTimePicker(null)}
>
<View className="flex-1 bg-white">
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Text className="text-[17px] font-semibold">
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<AppPressable onPress={() => setShowTimePicker(null)}>
<Ionicons name="close" size={24} color="#8E8E93" />
</AppPressable>
</View>
<ScrollView>
{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 (
<AppPressable
key={time}
onPress={() => handleTimeSelect(time)}
className={`border-b border-gray-100 px-4 py-3.5 ${
isSelected ? "bg-blue-50" : ""
}`}
>
<View className="flex-row items-center justify-between">
<Text
className={`text-[17px] ${
isSelected ? "font-medium text-[#007AFF]" : "text-black"
}`}
>
{formatTime12Hour(time)}
</Text>
{isSelected && <Ionicons name="checkmark" size={20} color="#007AFF" />}
</View>
</AppPressable>
);
})}
</ScrollView>
</View>
</FullScreenModal>
</ScrollView>
);
});
export default EditAvailabilityDayScreen;
@@ -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<string, number> = {
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<number, ScheduleAvailability[]> => {
if (!schedule) return {};
const availabilityMap: Record<number, 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);
}
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<unknown, EditAvailabilityHoursScreenProps>(
function EditAvailabilityHoursScreen(
{ schedule, onDayPress, transparentBackground = false },
_ref
) {
const insets = useSafeAreaInsets();
const backgroundStyle = transparentBackground ? "bg-transparent" : "bg-[#F2F2F7]";
const availability = parseAvailability(schedule);
if (!schedule) {
return (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className={`flex-1 ${backgroundStyle}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={!transparentBackground}
>
<Text className="mb-3 px-1 text-[13px] font-medium text-[#8E8E93]">
Tap a day to edit its hours
</Text>
<View
className={`overflow-hidden rounded-xl ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
{DAYS.map((day, dayIndex) => {
const daySlots = availability[dayIndex] || [];
const isEnabled = daySlots.length > 0;
return (
<AppPressable
key={day}
className={`flex-row items-center px-4 py-3.5 ${
dayIndex > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
onPress={() => onDayPress(dayIndex)}
>
{/* Day status indicator */}
<View
className={`mr-3 h-2.5 w-2.5 rounded-full ${
isEnabled ? "bg-[#34C759]" : "bg-[#E5E5EA]"
}`}
/>
{/* Day name */}
<Text
className={`w-24 text-[17px] font-medium ${
isEnabled ? "text-black" : "text-[#8E8E93]"
}`}
>
{day}
</Text>
{/* Time slots or unavailable */}
<View className="flex-1">
{isEnabled ? (
daySlots.map((slot, slotIndex) => (
<Text
key={`${slotIndex}-${slot.startTime}`}
className={`text-[15px] text-[#8E8E93] ${slotIndex > 0 ? "mt-0.5" : ""}`}
>
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
</Text>
))
) : (
<Text className="text-[15px] text-[#8E8E93]">Unavailable</Text>
)}
</View>
{/* Chevron */}
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</AppPressable>
);
})}
</View>
</ScrollView>
);
}
);
export default EditAvailabilityHoursScreen;
@@ -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<string, number> = {
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<number, ScheduleAvailability[]> => {
if (!schedule) return {};
const availabilityMap: Record<number, 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);
}
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<unknown, EditAvailabilityHoursScreenProps>(
function EditAvailabilityHoursScreen({ schedule, onDayPress }, _ref) {
const insets = useSafeAreaInsets();
const availability = parseAvailability(schedule);
if (!schedule) {
return (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-gray-500">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-white"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
>
<Text className="mb-3 text-[13px] font-medium text-gray-500">
Tap a day to edit its hours
</Text>
<View className="overflow-hidden rounded-lg border border-gray-200">
{DAYS.map((day, dayIndex) => {
const daySlots = availability[dayIndex] || [];
const isEnabled = daySlots.length > 0;
return (
<AppPressable
key={day}
className={`flex-row items-center px-4 py-3.5 ${
dayIndex > 0 ? "border-t border-gray-200" : ""
}`}
onPress={() => onDayPress(dayIndex)}
>
{/* Day status indicator */}
<View
className={`mr-3 h-2.5 w-2.5 rounded-full ${
isEnabled ? "bg-green-500" : "bg-gray-300"
}`}
/>
{/* Day name */}
<Text
className={`w-24 text-[17px] font-medium ${
isEnabled ? "text-black" : "text-gray-400"
}`}
>
{day}
</Text>
{/* Time slots or unavailable */}
<View className="flex-1">
{isEnabled ? (
daySlots.map((slot, slotIndex) => (
<Text
key={`${slotIndex}-${slot.startTime}`}
className={`text-[15px] text-gray-500 ${slotIndex > 0 ? "mt-0.5" : ""}`}
>
{formatTime12Hour(slot.startTime)} – {formatTime12Hour(slot.endTime)}
</Text>
))
) : (
<Text className="text-[15px] text-gray-400">Unavailable</Text>
)}
</View>
{/* Chevron */}
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</AppPressable>
);
})}
</View>
</ScrollView>
);
}
);
export default EditAvailabilityHoursScreen;
@@ -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 (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<KeyboardAvoidingView behavior="padding" className={`flex-1 ${backgroundStyle}`}>
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={!transparentBackground}
>
{transparentBackground ? (
<>
{/* Name Input - Glass UI */}
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Schedule Name</Text>
<View className="mb-4 overflow-hidden rounded-xl border border-gray-300/40 bg-white/60">
<TextInput
className="px-4 py-3.5 text-[17px] text-black"
placeholder="Enter schedule name"
placeholderTextColor="#9CA3AF"
value={name}
onChangeText={setName}
autoCapitalize="words"
editable={!isSaving}
/>
</View>
{/* Timezone Selector - Glass UI */}
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Timezone</Text>
<View className="mb-4 flex-row items-center rounded-xl border border-gray-300/40 bg-white/60 px-4 py-3">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/20">
<Ionicons name="globe-outline" size={20} color="#007AFF" />
</View>
<View className="flex-1">
<Text className="text-[17px] font-medium text-black">{selectedTimezoneLabel}</Text>
<Text className="mt-0.5 text-[13px] text-[#8E8E93]">{timezone}</Text>
</View>
{/* Native iOS Context Menu Button */}
<Host matchContents>
<ContextMenu
modifiers={[
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
padding(),
]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{TIMEZONES.map((tz) => (
<Button
key={tz.id}
systemImage={timezone === tz.id ? "checkmark" : "globe"}
onPress={() => handleTimezoneSelect(tz.id)}
label={tz.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="chevron.up.chevron.down"
color="primary"
size={16}
modifiers={[frame({ height: 16, width: 16 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</>
) : (
<>
{/* Name Input */}
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Schedule Name
</Text>
<View className="mb-4 overflow-hidden rounded-xl bg-white">
<TextInput
className="px-4 py-3.5 text-[17px] text-black"
placeholder="Enter schedule name"
placeholderTextColor="#9CA3AF"
value={name}
onChangeText={setName}
autoCapitalize="words"
editable={!isSaving}
/>
</View>
{/* Timezone Selector */}
<Text className="mb-2 px-1 text-[13px] font-medium uppercase tracking-wide text-[#8E8E93]">
Timezone
</Text>
<View className="mb-4 flex-row items-center rounded-xl bg-white px-4 py-3">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
<Ionicons name="globe-outline" size={20} color="#007AFF" />
</View>
<View className="flex-1">
<Text className="text-[17px] font-medium text-black">{selectedTimezoneLabel}</Text>
<Text className="mt-0.5 text-[13px] text-[#8E8E93]">{timezone}</Text>
</View>
{/* Native iOS Context Menu Button */}
<Host matchContents>
<ContextMenu
modifiers={[
buttonStyle(isLiquidGlassAvailable() ? "glass" : "bordered"),
padding(),
]}
activationMethod="singlePress"
>
<ContextMenu.Items>
{TIMEZONES.map((tz) => (
<Button
key={tz.id}
systemImage={timezone === tz.id ? "checkmark" : "globe"}
onPress={() => handleTimezoneSelect(tz.id)}
label={tz.label}
/>
))}
</ContextMenu.Items>
<ContextMenu.Trigger>
<HStack>
<Image
systemName="chevron.up.chevron.down"
color="primary"
size={16}
modifiers={[frame({ height: 16, width: 16 })]}
/>
</HStack>
</ContextMenu.Trigger>
</ContextMenu>
</Host>
</View>
</>
)}
</ScrollView>
</KeyboardAvoidingView>
);
});
export default EditAvailabilityNameScreen;
@@ -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 (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-gray-500">No schedule data</Text>
</View>
);
}
return (
<KeyboardAvoidingView behavior="padding" className="flex-1 bg-white">
<ScrollView
className="flex-1"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
keyboardShouldPersistTaps="handled"
>
{/* Name Input */}
<Text className="mb-2 text-[13px] font-medium uppercase tracking-wide text-gray-500">
Schedule Name
</Text>
<TextInput
className="mb-4 rounded-lg border border-gray-300 bg-gray-50 px-4 py-3 text-[17px] text-black"
placeholder="Enter schedule name"
placeholderTextColor="#9CA3AF"
value={name}
onChangeText={setName}
autoCapitalize="words"
editable={!isSaving}
/>
{/* Timezone Selector */}
<Text className="mb-2 text-[13px] font-medium uppercase tracking-wide text-gray-500">
Timezone
</Text>
<AppPressable
className="flex-row items-center justify-between rounded-lg border border-gray-300 bg-gray-50 px-4 py-3"
onPress={() => setShowTimezoneModal(true)}
>
<View className="flex-row items-center">
<Ionicons name="globe-outline" size={20} color="#007AFF" style={{ marginRight: 12 }} />
<View>
<Text className="text-[17px] text-black">{selectedTimezoneLabel}</Text>
<Text className="mt-0.5 text-[13px] text-gray-500">{timezone}</Text>
</View>
</View>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</AppPressable>
</ScrollView>
{/* Timezone Modal */}
<FullScreenModal
visible={showTimezoneModal}
animationType="slide"
onRequestClose={() => setShowTimezoneModal(false)}
>
<View className="flex-1 bg-white">
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Text className="text-[17px] font-semibold">Select Timezone</Text>
<AppPressable onPress={() => setShowTimezoneModal(false)}>
<Ionicons name="close" size={24} color="#8E8E93" />
</AppPressable>
</View>
<ScrollView>
{TIMEZONES.map((tz) => (
<AppPressable
key={tz.id}
onPress={() => {
setTimezone(tz.id);
setShowTimezoneModal(false);
}}
className={`border-b border-gray-100 px-4 py-3.5 ${
tz.id === timezone ? "bg-blue-50" : ""
}`}
>
<View className="flex-row items-center justify-between">
<View>
<Text
className={`text-[17px] ${
tz.id === timezone ? "font-medium text-[#007AFF]" : "text-black"
}`}
>
{tz.label}
</Text>
<Text className="text-[13px] text-gray-500">{tz.id}</Text>
</View>
{tz.id === timezone && <Ionicons name="checkmark" size={20} color="#007AFF" />}
</View>
</AppPressable>
))}
</ScrollView>
</View>
</FullScreenModal>
</KeyboardAvoidingView>
);
});
export default EditAvailabilityNameScreen;
@@ -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<Date>(new Date());
const [isUnavailable, setIsUnavailable] = useState(false);
const [startTime, setStartTime] = useState<Date>(timeStringToDate("09:00"));
const [endTime, setEndTime] = useState<Date>(timeStringToDate("17:00"));
const [isSaving, setIsSaving] = useState(false);
// Initialize from existing override if editing
useEffect(() => {
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 (
<View className={`flex-1 items-center justify-center ${backgroundStyle}`}>
<Text className="text-[#8E8E93]">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className={`flex-1 ${backgroundStyle}`}
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
showsVerticalScrollIndicator={!transparentBackground}
>
{/* Date Picker */}
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Date</Text>
<View
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#007AFF]/10">
<Ionicons name="calendar" size={20} color="#007AFF" />
</View>
<Text className="text-[17px] text-black">{formatDateForDisplay(selectedDate)}</Text>
</View>
</View>
<View className="mt-3">
<Host matchContents>
<DatePicker
onDateChange={handleDateChange}
displayedComponents={["date"]}
selection={selectedDate}
/>
</Host>
</View>
</View>
{/* Unavailable Toggle */}
<View
className={`mb-4 flex-row items-center justify-between rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<View className="mr-3 h-9 w-9 items-center justify-center rounded-lg bg-[#FF3B30]/10">
<Ionicons name="moon-outline" size={20} color="#FF3B30" />
</View>
<View>
<Text className="text-[17px] text-black">Mark as unavailable</Text>
<Text className="text-[13px] text-[#8E8E93]">Block this entire day</Text>
</View>
</View>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
thumbColor="#fff"
/>
</View>
{/* Time Range (only when available) */}
{!isUnavailable && (
<>
<Text className="mb-2 px-1 text-[13px] font-medium text-[#8E8E93]">Available Hours</Text>
<View
className={`mb-4 rounded-xl px-4 py-3.5 ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
<View className="flex-row items-center">
<View className="flex-1">
<Text className="mb-2 text-[13px] text-[#8E8E93]">Start Time</Text>
<Host matchContents>
<DatePicker
onDateChange={handleStartTimeChange}
displayedComponents={["hourAndMinute"]}
selection={startTime}
/>
</Host>
</View>
<Text className="mx-4 text-[17px] text-[#8E8E93]">–</Text>
<View className="flex-1">
<Text className="mb-2 text-[13px] text-[#8E8E93]">End Time</Text>
<Host matchContents>
<DatePicker
onDateChange={handleEndTimeChange}
displayedComponents={["hourAndMinute"]}
selection={endTime}
/>
</Host>
</View>
</View>
</View>
</>
)}
{/* Existing Overrides (if any) */}
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
<>
<Text className="mb-2 mt-4 px-1 text-[13px] font-medium text-[#8E8E93]">
Existing Overrides
</Text>
<View
className={`overflow-hidden rounded-xl ${
transparentBackground ? "border border-gray-300/40 bg-white/60" : "bg-white"
}`}
>
{schedule.overrides.map((override, index) => (
<Pressable
key={override.date}
className={`flex-row items-center justify-between px-4 py-3 ${
index > 0 ? "border-t border-[#E5E5EA]" : ""
}`}
onPress={() => onEditOverride?.(index)}
>
<View className="flex-1">
<Text className="text-[15px] text-black">
{formatDateForDisplay(dateStringToDate(override.date ?? ""))}
</Text>
{override.startTime === "00:00" && override.endTime === "00:00" ? (
<Text className="text-[13px] text-[#FF3B30]">Unavailable</Text>
) : (
<Text className="text-[13px] text-[#8E8E93]">
{formatTime12Hour(override.startTime ?? "00:00")} –{" "}
{formatTime12Hour(override.endTime ?? "00:00")}
</Text>
)}
</View>
<View className="flex-row items-center">
<Pressable
className="rounded-full bg-[#FF3B30]/10 p-2"
onPress={() => handleDeleteOverride(index)}
>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
</Pressable>
<Pressable
className="ml-3 rounded-full bg-[#8E8E93]/10 p-2"
onPress={() => onEditOverride?.(index)}
>
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
</Pressable>
</View>
</Pressable>
))}
</View>
</>
)}
</ScrollView>
);
});
export default EditAvailabilityOverrideScreen;
@@ -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 (
<View className="flex-1 items-center justify-center bg-white">
<Text className="text-gray-500">No schedule data</Text>
</View>
);
}
return (
<ScrollView
className="flex-1 bg-white"
contentContainerStyle={{
padding: 16,
paddingBottom: insets.bottom + 16,
}}
>
{/* Date Input */}
<Text className="mb-2 text-[13px] font-medium text-gray-500">Date (YYYY-MM-DD)</Text>
<View className="mb-4 rounded-lg border border-gray-200 px-4 py-3">
<View className="flex-row items-center">
<Ionicons name="calendar" size={20} color="#007AFF" style={{ marginRight: 12 }} />
<TextInput
className="flex-1 text-[17px] text-black"
placeholder="2024-12-25"
placeholderTextColor="#9CA3AF"
value={selectedDate}
onChangeText={setSelectedDate}
/>
</View>
{selectedDate && (
<Text className="mt-2 text-[15px] text-gray-500">
{formatDateForDisplay(selectedDate)}
</Text>
)}
</View>
{/* Unavailable Toggle */}
<View className="mb-4 flex-row items-center justify-between rounded-lg border border-gray-200 px-4 py-3.5">
<View className="flex-row items-center">
<Ionicons name="moon-outline" size={20} color="#FF3B30" style={{ marginRight: 12 }} />
<View>
<Text className="text-[17px] text-black">Mark as unavailable</Text>
<Text className="text-[13px] text-gray-500">Block this entire day</Text>
</View>
</View>
<Switch
value={isUnavailable}
onValueChange={setIsUnavailable}
trackColor={{ false: "#E5E5EA", true: "#FF3B30" }}
thumbColor="#fff"
/>
</View>
{/* Time Range (only when available) */}
{!isUnavailable && (
<>
<Text className="mb-2 text-[13px] font-medium text-gray-500">Available Hours</Text>
<View className="mb-4 rounded-lg border border-gray-200 px-4 py-3.5">
<View className="flex-row items-center">
<AppPressable
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
onPress={() => setShowTimePicker({ type: "start" })}
>
<Text className="text-[13px] text-gray-500">Start Time</Text>
<Text className="text-[17px] text-black">{formatTime12Hour(startTime)}</Text>
</AppPressable>
<Text className="mx-4 text-[17px] text-gray-400">–</Text>
<AppPressable
className="flex-1 rounded-lg bg-gray-100 px-3 py-2"
onPress={() => setShowTimePicker({ type: "end" })}
>
<Text className="text-[13px] text-gray-500">End Time</Text>
<Text className="text-[17px] text-black">{formatTime12Hour(endTime)}</Text>
</AppPressable>
</View>
</View>
</>
)}
{/* Existing Overrides */}
{!isEditing && schedule.overrides && schedule.overrides.length > 0 && (
<>
<Text className="mb-2 mt-4 text-[13px] font-medium text-gray-500">
Existing Overrides
</Text>
<View className="overflow-hidden rounded-lg border border-gray-200">
{schedule.overrides.map((override, index) => (
<AppPressable
key={override.date}
className={`flex-row items-center justify-between px-4 py-3 ${
index > 0 ? "border-t border-gray-200" : ""
}`}
onPress={() => onEditOverride?.(index)}
>
<View className="flex-1">
<Text className="text-[15px] text-black">
{formatDateForDisplay(override.date ?? "")}
</Text>
{override.startTime === "00:00" && override.endTime === "00:00" ? (
<Text className="text-[13px] text-red-500">Unavailable</Text>
) : (
<Text className="text-[13px] text-gray-500">
{formatTime12Hour(override.startTime ?? "00:00")} –{" "}
{formatTime12Hour(override.endTime ?? "00:00")}
</Text>
)}
</View>
<View className="flex-row items-center">
<AppPressable
className="rounded-full bg-red-100 p-2"
onPress={() => handleDeleteOverride(index)}
>
<Ionicons name="trash-outline" size={18} color="#FF3B30" />
</AppPressable>
<AppPressable
className="ml-3 rounded-full bg-gray-100 p-2"
onPress={() => onEditOverride?.(index)}
>
<Ionicons name="chevron-forward" size={18} color="#8E8E93" />
</AppPressable>
</View>
</AppPressable>
))}
</View>
</>
)}
{/* Time Picker Modal */}
<FullScreenModal
visible={!!showTimePicker}
animationType="slide"
onRequestClose={() => setShowTimePicker(null)}
>
<View className="flex-1 bg-white">
<View className="flex-row items-center justify-between border-b border-gray-200 px-4 py-3">
<Text className="text-[17px] font-semibold">
Select {showTimePicker?.type === "start" ? "Start" : "End"} Time
</Text>
<AppPressable onPress={() => setShowTimePicker(null)}>
<Ionicons name="close" size={24} color="#8E8E93" />
</AppPressable>
</View>
<ScrollView>
{TIME_OPTIONS.map((time) => {
const currentTime = showTimePicker?.type === "start" ? startTime : endTime;
const isSelected = currentTime === time;
return (
<AppPressable
key={time}
onPress={() => handleTimeSelect(time)}
className={`border-b border-gray-100 px-4 py-3.5 ${
isSelected ? "bg-blue-50" : ""
}`}
>
<View className="flex-row items-center justify-between">
<Text
className={`text-[17px] ${
isSelected ? "font-medium text-[#007AFF]" : "text-black"
}`}
>
{formatTime12Hour(time)}
</Text>
{isSelected && <Ionicons name="checkmark" size={20} color="#007AFF" />}
</View>
</AppPressable>
);
})}
</ScrollView>
</View>
</FullScreenModal>
</ScrollView>
);
});
export default EditAvailabilityOverrideScreen;
@@ -125,7 +125,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
);
const handleSubmit = useCallback(async () => {
if (!booking) return;
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -171,7 +171,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
Alert.alert("Error", "Failed to update location. Please try again.");
setIsSaving(false);
}
}, [booking, selectedType, inputValue, onSuccess]);
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
// Expose submit function to parent via ref
useImperativeHandle(
@@ -131,7 +131,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
const selectedTypeConfig = LOCATION_TYPES[selectedType];
const handleSubmit = useCallback(async () => {
if (!booking) return;
if (!booking || isSaving) return;
const trimmedValue = inputValue.trim();
@@ -176,7 +176,7 @@ export const EditLocationScreen = forwardRef<EditLocationScreenHandle, EditLocat
Alert.alert("Error", "Failed to update location. Please try again.");
setIsSaving(false);
}
}, [booking, selectedType, inputValue, onSuccess]);
}, [booking, selectedType, inputValue, onSuccess, isSaving]);
useImperativeHandle(
ref,
@@ -69,7 +69,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
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<RescheduleScreenHandle, RescheduleScr
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess]);
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
// Format date for display
const formattedDate = selectedDateTime.toLocaleDateString(undefined, {
@@ -53,7 +53,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
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<RescheduleScreenHandle, RescheduleScr
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess]);
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
const handleDateSelected = useCallback(
(date: Date) => {
@@ -70,7 +70,7 @@ export const RescheduleScreen = forwardRef<RescheduleScreenHandle, RescheduleScr
}, [isSaving, onSavingChange]);
const handleSubmit = useCallback(async () => {
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<RescheduleScreenHandle, RescheduleScr
Alert.alert("Error", "Failed to reschedule booking. Please try again.");
setIsSaving(false);
}
}, [booking, selectedDateTime, reason, onSuccess]);
}, [booking, selectedDateTime, reason, onSuccess, isSaving]);
// Helper function to format date as YYYY-MM-DD in local timezone (avoids UTC conversion issues)
const formatLocalDate = (date: Date) => {
+424
View File
@@ -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];
+4
View File
@@ -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",