Calendar improvements

This commit is contained in:
Milan Paunovic
2025-02-02 22:28:40 +01:00
parent 0998dc29d0
commit 5cfec1544a
22 changed files with 5140 additions and 1188 deletions

12
.idea/material_theme_project_new.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="migrated" value="true" />
<option name="pristineConfig" value="false" />
<option name="userId" value="-7d3b2185:193a8bd7023:-7ffe" />
</MTProjectMetadataState>
</option>
</component>
</project>

View File

@ -1,433 +1,356 @@
import React from "react";
import { Drawer } from "expo-router/drawer";
import { useSignOut } from "@/hooks/firebase/useSignOut";
import {
DrawerContentScrollView,
DrawerNavigationOptions,
DrawerNavigationProp,
} from "@react-navigation/drawer";
import {
Button,
ButtonSize,
Text,
TouchableOpacity,
View,
} from "react-native-ui-lib";
import { ImageBackground, Pressable, StyleSheet } from "react-native";
import React, {useCallback} from "react";
import {Drawer} from "expo-router/drawer";
import {DrawerContentScrollView, DrawerContentComponentProps, DrawerNavigationOptions} from "@react-navigation/drawer";
import {ImageBackground, Pressable, StyleSheet} from "react-native";
import {Button, ButtonSize, Text, View} from "react-native-ui-lib";
import * as Device from "expo-device";
import {useSetAtom} from "jotai";
import {Ionicons} from "@expo/vector-icons";
import {DeviceType} from "expo-device";
import {DrawerNavigationProp} from "@react-navigation/drawer";
import {ParamListBase, Theme} from '@react-navigation/native';
import {RouteProp} from "@react-navigation/native";
import {useSignOut} from "@/hooks/firebase/useSignOut";
import {CalendarHeader} from "@/components/pages/calendar/CalendarHeader";
import DrawerButton from "@/components/shared/DrawerButton";
import DrawerIcon from "@/assets/svgs/DrawerIcon";
import NavGroceryIcon from "@/assets/svgs/NavGroceryIcon";
import NavToDosIcon from "@/assets/svgs/NavToDosIcon";
import NavBrainDumpIcon from "@/assets/svgs/NavBrainDumpIcon";
import NavCalendarIcon from "@/assets/svgs/NavCalendarIcon";
import NavSettingsIcon from "@/assets/svgs/NavSettingsIcon";
import ViewSwitch from "@/components/pages/(tablet_pages)/ViewSwitch";
import { useSetAtom } from "jotai";
import {
isFamilyViewAtom,
settingsPageIndex,
toDosPageIndex,
userSettingsView,
} from "@/components/pages/calendar/atoms";
import Ionicons from "@expo/vector-icons/Ionicons";
import * as Device from "expo-device";
import { DeviceType } from "expo-device";
import FeedbackNavIcon from "@/assets/svgs/FeedbackNavIcon";
import DrawerIcon from "@/assets/svgs/DrawerIcon";
import { RouteProp } from "@react-navigation/core";
import RefreshButton from "@/components/shared/RefreshButton";
import { useCalSync } from "@/hooks/useCalSync";
import {useIsFetching} from "@tanstack/react-query";
import { CalendarHeader } from "@/components/pages/calendar/CalendarHeader";
import {
isFamilyViewAtom,
settingsPageIndex,
toDosPageIndex,
userSettingsView,
} from "@/components/pages/calendar/atoms";
import ViewSwitch from "@/components/pages/(tablet_pages)/ViewSwitch";
const isTablet = Device.deviceType === DeviceType.TABLET;
type DrawerParamList = {
index: undefined;
calendar: undefined;
todos: undefined;
index: undefined;
calendar: undefined;
brain_dump: undefined;
settings: undefined;
grocery: undefined;
reminders: undefined;
todos: undefined;
notifications: undefined;
feedback: undefined;
};
type NavigationProp = DrawerNavigationProp<DrawerParamList>;
type DrawerScreenNavigationProp = DrawerNavigationProp<DrawerParamList>;
interface ViewSwitchProps {
navigation: NavigationProp;
interface DrawerButtonConfig {
id: string;
title: string;
color: string;
bgColor: string;
icon: React.FC;
route: keyof DrawerParamList;
}
const DRAWER_BUTTONS: DrawerButtonConfig[] = [
{
id: 'calendar',
title: 'Calendar',
color: 'rgb(7, 184, 199)',
bgColor: 'rgb(231, 248, 250)',
icon: NavCalendarIcon,
route: 'calendar'
},
{
id: 'grocery',
title: 'Groceries',
color: '#50be0c',
bgColor: '#eef9e7',
icon: NavGroceryIcon,
route: 'grocery'
},
{
id: 'feedback',
title: 'Feedback',
color: '#ea156d',
bgColor: '#fdedf4',
icon: FeedbackNavIcon,
route: 'feedback'
},
{
id: 'todos',
title: 'To Dos',
color: '#8005eb',
bgColor: '#f3e6fd',
icon: NavToDosIcon,
route: 'todos'
},
{
id: 'brain_dump',
title: 'Brain Dump',
color: '#e0ca03',
bgColor: '#fffacb',
icon: NavBrainDumpIcon,
route: 'brain_dump'
},
{
id: 'notifications',
title: 'Notifications',
color: '#ffa200',
bgColor: '#ffdda1',
icon: () => <Ionicons name="notifications-outline" size={24} color="#ffa200"/>,
route: 'notifications'
}
];
interface DrawerContentProps {
props: DrawerContentComponentProps;
}
const DrawerContent: React.FC<DrawerContentProps> = ({props}) => {
const {mutateAsync: signOut} = useSignOut();
const setIsFamilyView = useSetAtom(isFamilyViewAtom);
const setPageIndex = useSetAtom(settingsPageIndex);
const setUserView = useSetAtom(userSettingsView);
const setToDosIndex = useSetAtom(toDosPageIndex);
const handleNavigation = useCallback((route: keyof DrawerParamList) => {
props.navigation.navigate(route);
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}, [props.navigation, setPageIndex, setToDosIndex, setUserView, setIsFamilyView]);
const renderDrawerButtons = () => {
const midPoint = Math.ceil(DRAWER_BUTTONS.length / 2);
const leftButtons = DRAWER_BUTTONS.slice(0, midPoint);
const rightButtons = DRAWER_BUTTONS.slice(midPoint);
return (
<View row paddingH-30>
<View flex-1 paddingR-5>
{leftButtons.map(button => (
<DrawerButton
key={button.id}
title={button.title}
color={button.color}
bgColor={button.bgColor}
pressFunc={() => handleNavigation(button.route)}
icon={<button.icon/>}
/>
))}
</View>
<View flex-1>
{rightButtons.map(button => (
<DrawerButton
key={button.id}
title={button.title}
color={button.color}
bgColor={button.bgColor}
pressFunc={() => handleNavigation(button.route)}
icon={<button.icon/>}
/>
))}
</View>
</View>
);
};
return (
<DrawerContentScrollView {...props}>
<View centerV marginH-30 marginT-20 marginB-20 row>
<ImageBackground
source={require("../../assets/images/splash.png")}
style={styles.logo}
/>
<Text style={styles.title}>Welcome to Cally</Text>
</View>
{renderDrawerButtons()}
<Button
onPress={() => handleNavigation('settings')}
label="Manage Settings"
labelStyle={styles.label}
iconSource={() => (
<View style={styles.settingsIcon}>
<NavSettingsIcon/>
</View>
)}
backgroundColor="white"
color="#464039"
paddingV-30
marginH-30
borderRadius={18.55}
style={{elevation: 0}}
/>
<Button
size={ButtonSize.large}
style={styles.signOutButton}
label="Sign out of Cally"
color="#fd1775"
labelStyle={styles.signOut}
onPress={() => signOut()}
/>
</DrawerContentScrollView>
);
};
interface HeaderRightProps {
routeName: keyof DrawerParamList;
navigation: NavigationProp;
route: RouteProp<DrawerParamList>;
navigation: DrawerScreenNavigationProp;
}
const MemoizedViewSwitch = React.memo<ViewSwitchProps>(({ navigation }) => (
<ViewSwitch navigation={navigation} />
));
const HeaderRight: React.FC<HeaderRightProps> = ({route, navigation}) => {
const showViewSwitch = ["calendar", "todos", "index"].includes(route.name);
const isCalendarPage = ["calendar", "index"].includes(route.name);
const HeaderRight = React.memo<HeaderRightProps>(
({ routeName, navigation }) => {
const showViewSwitch = ["calendar", "todos", "index"].includes(routeName);
if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
return null;
if (!isTablet || !showViewSwitch) {
return isCalendarPage ? <CalendarHeader/> : null;
}
return <MemoizedViewSwitch navigation={navigation} />;
}
);
export default function TabLayout() {
const { mutateAsync: signOut } = useSignOut();
const setIsFamilyView = useSetAtom(isFamilyViewAtom);
const setPageIndex = useSetAtom(settingsPageIndex);
const setUserView = useSetAtom(userSettingsView);
const setToDosIndex = useSetAtom(toDosPageIndex);
const { resyncAllCalendars, isSyncing } = useCalSync();
return (
<View marginR-16 row centerV>
{isTablet && isCalendarPage && (
<View flex-1 center>
<CalendarHeader/>
</View>
)}
<ViewSwitch navigation={navigation}/>
</View>
);
};
const isFetching = useIsFetching({queryKey: ['events']}) > 0;
const isLoading = React.useMemo(() => {
return isSyncing || isFetching;
}, [isSyncing, isFetching]);
const onRefresh = React.useCallback(async () => {
try {
await resyncAllCalendars();
} catch (error) {
console.error("Refresh failed:", error);
}
}, [resyncAllCalendars]);
const screenOptions = ({
navigation,
route,
}: {
navigation: DrawerNavigationProp<DrawerParamList>;
route: RouteProp<DrawerParamList>;
}): DrawerNavigationOptions => ({
const screenOptions: (props: {
route: RouteProp<ParamListBase, string>;
navigation: DrawerNavigationProp<ParamListBase, string>;
theme: Theme;
}) => DrawerNavigationOptions = ({route, navigation}) => ({
lazy: true,
headerShown: true,
headerTitleAlign:
Device.deviceType === DeviceType.TABLET ? "left" : "unaligned",
headerTitle: ({ children }) => {
headerTitleAlign: "left",
headerTitle: ({children}) => {
const isCalendarRoute = ["calendar", "index"].includes(route.name);
if (isCalendarRoute && Device.deviceType !== DeviceType.TABLET) {
return <View centerV><CalendarHeader /></View>;
}
if (isCalendarRoute) return null;
return (
<Text
style={{
fontFamily: "Manrope_600SemiBold",
fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17,
}}
>
{children}
</Text>
<View flexG centerV paddingL-10>
<Text style={styles.headerTitle}>
{children}
</Text>
</View>
);
},
headerTitleStyle: {
fontFamily: "Manrope_600SemiBold",
fontSize: Device.deviceType === DeviceType.TABLET ? 22 : 17,
},
headerLeft: () => (
<Pressable
onPress={() => {
navigation.toggleDrawer()
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
style={({ pressed }) => [
{
marginLeft: 16,
opacity: pressed ? 0.4 : 1
}
]}
>
<DrawerIcon />
</Pressable>
<Pressable
onPress={() => navigation.toggleDrawer()}
hitSlop={{top: 10, bottom: 10, left: 10, right: 10}}
style={({pressed}) => [styles.drawerTrigger, {opacity: pressed ? 0.4 : 1}]}
>
<DrawerIcon/>
</Pressable>
),
headerRight: () => {
const showViewSwitch = ["calendar", "todos", "index"].includes(
route.name
);
const isCalendarPage = ["calendar", "index"].includes(route.name);
headerRight: () => <HeaderRight
route={route as RouteProp<DrawerParamList>}
navigation={navigation as DrawerNavigationProp<DrawerParamList>}
/>,
drawerStyle: styles.drawer,
});
if (Device.deviceType !== DeviceType.TABLET || !showViewSwitch) {
return isCalendarPage ? (
<View marginR-8 >
<RefreshButton onRefresh={onRefresh} isSyncing={isLoading} />
</View>
) : null;
}
return (
<View marginR-16 row centerV>
{Device.deviceType === DeviceType.TABLET && isCalendarPage && <View flex-1 center><CalendarHeader />
</View>}
{isCalendarPage && (
<View marginR-16><RefreshButton onRefresh={onRefresh} isSyncing={isLoading} /></View>
)}
<MemoizedViewSwitch navigation={navigation} />
</View>
);
},
drawerStyle: {
width: Device.deviceType === DeviceType.TABLET ? "30%" : "90%",
backgroundColor: "#f9f8f7",
height: "100%",
},
});
return (
<Drawer
initialRouteName={"index"}
detachInactiveScreens
screenOptions={screenOptions}
drawerContent={(props) => {
return (
<DrawerContentScrollView {...props} style={{}}>
<View centerV marginH-30 marginT-20 marginB-20 row>
<ImageBackground
source={require("../../assets/images/splash.png")}
style={{
backgroundColor: "transparent",
height: 51.43,
aspectRatio: 1,
marginRight: 8,
}}
/>
<Text style={styles.title}>Welcome to Cally</Text>
</View>
<View
style={{
flexDirection: "row",
paddingHorizontal: 30,
}}
>
<View style={{ flex: 1, paddingRight: 5 }}>
<DrawerButton
title={"Calendar"}
color="rgb(7, 184, 199)"
bgColor={"rgb(231, 248, 250)"}
pressFunc={() => {
props.navigation.navigate("calendar");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={<NavCalendarIcon />}
/>
<DrawerButton
color="#50be0c"
title={"Groceries"}
bgColor={"#eef9e7"}
pressFunc={() => {
props.navigation.navigate("grocery");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={<NavGroceryIcon />}
/>
<DrawerButton
color="#ea156d"
title={"Feedback"}
bgColor={"#fdedf4"}
pressFunc={() => {
props.navigation.navigate("feedback");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={<FeedbackNavIcon />}
/>
</View>
<View style={{ flex: 1, paddingRight: 0 }}>
{/*<DrawerButton
color="#fd1775"
title={"My Reminders"}
bgColor={"#ffe8f2"}
pressFunc={() => props.navigation.navigate("reminders")}
icon={
<FontAwesome6
name="clock-rotate-left"
size={28}
color="#fd1775"
/>
}
/>*/}
<DrawerButton
color="#8005eb"
title={"To Dos"}
bgColor={"#f3e6fd"}
pressFunc={() => {
props.navigation.navigate("todos");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={<NavToDosIcon />}
/>
<DrawerButton
color="#e0ca03"
title={"Brain Dump"}
bgColor={"#fffacb"}
pressFunc={() => {
props.navigation.navigate("brain_dump");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={<NavBrainDumpIcon />}
/>
<DrawerButton
color="#e0ca03"
title={"Notifications"}
bgColor={"#ffdda1"}
pressFunc={() => {
props.navigation.navigate("notifications");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
icon={
<Ionicons
name="notifications-outline"
size={24}
color={"#ffa200"}
/>
}
/>
</View>
</View>
<Button
onPress={() => {
props.navigation.navigate("settings");
setPageIndex(0);
setToDosIndex(0);
setUserView(true);
setIsFamilyView(false);
}}
label={"Manage Settings"}
labelStyle={styles.label}
iconSource={() => (
<View
backgroundColor="#ededed"
width={60}
height={60}
style={{ borderRadius: 50 }}
marginR-10
centerV
centerH
>
<NavSettingsIcon />
</View>
)}
backgroundColor="white"
color="#464039"
paddingV-30
marginH-30
borderRadius={18.55}
style={{ elevation: 0 }}
/>
<Button
size={ButtonSize.large}
marginH-10
marginT-12
paddingV-15
style={{
marginTop: 50,
backgroundColor: "transparent",
borderWidth: 1.3,
borderColor: "#fd1775",
}}
label="Sign out of Cally"
color="#fd1775"
labelStyle={styles.signOut}
onPress={() => signOut()}
/>
</DrawerContentScrollView>
);
}}
>
<Drawer.Screen
name="index"
options={{
drawerLabel: "Calendar",
title: "Calendar",
}}
/>
<Drawer.Screen
name="calendar"
options={{
drawerLabel: "Calendar",
title: "Calendar",
drawerItemStyle: { display: "none" },
}}
/>
<Drawer.Screen
name="brain_dump"
options={{
drawerLabel: "Brain Dump",
title: "Brain Dump",
}}
/>
<Drawer.Screen
name="settings"
options={{
drawerLabel: "Settings",
title: "Settings",
}}
/>
<Drawer.Screen
name="grocery"
options={{
drawerLabel: "Groceries",
title: "Groceries",
}}
/>
<Drawer.Screen
name="reminders"
options={{
drawerLabel: "Reminders",
title: "Reminders",
}}
/>
<Drawer.Screen
name="todos"
options={{
drawerLabel: "To-Do",
title:
Device.deviceType === DeviceType.TABLET
? "Family To Dos"
: "To Dos",
}}
/>
<Drawer.Screen
name="notifications"
options={{
drawerLabel: "Notifications",
title: "Notifications",
}}
/>
<Drawer.Screen
name="feedback"
options={{ drawerLabel: "Feedback", title: "Feedback" }}
/>
</Drawer>
);
interface DrawerScreen {
name: keyof DrawerParamList;
title: string;
hideInDrawer?: boolean;
}
const DRAWER_SCREENS: DrawerScreen[] = [
{name: 'index', title: 'Calendar'},
{name: 'calendar', title: 'Calendar', hideInDrawer: true},
{name: 'brain_dump', title: 'Brain Dump'},
{name: 'settings', title: 'Settings'},
{name: 'grocery', title: 'Groceries'},
{name: 'reminders', title: 'Reminders'},
{name: 'todos', title: isTablet ? 'Family To Dos' : 'To Dos'},
{name: 'notifications', title: 'Notifications'},
{name: 'feedback', title: 'Feedback'}
];
const TabLayout: React.FC = () => {
return (
<Drawer
initialRouteName="index"
detachInactiveScreens
screenOptions={screenOptions}
drawerContent={(props) => <DrawerContent props={props}/>}
>
{DRAWER_SCREENS.map(screen => (
<Drawer.Screen
key={screen.name}
name={screen.name}
options={{
drawerLabel: screen.title,
title: screen.title,
...(screen.hideInDrawer && {drawerItemStyle: {display: 'none'}}),
}}
/>
))}
</Drawer>
);
};
const styles = StyleSheet.create({
signOut: { fontFamily: "Poppins_500Medium", fontSize: 15 },
label: { fontFamily: "Poppins_400Medium", fontSize: 15 },
title: {
fontSize: 26.13,
fontFamily: "Manrope_600SemiBold",
color: "#262627",
},
drawer: {
width: isTablet ? "30%" : "90%",
backgroundColor: "#f9f8f7",
height: "100%",
},
drawerTrigger: {
marginLeft: 16,
},
headerTitle: {
fontFamily: "Manrope_600SemiBold",
fontSize: isTablet ? 22 : 17,
},
logo: {
backgroundColor: "transparent",
height: 51.43,
aspectRatio: 1,
marginRight: 8,
},
settingsIcon: {
backgroundColor: "#ededed",
width: 60,
height: 60,
borderRadius: 50,
marginRight: 10,
justifyContent: 'center',
alignItems: 'center',
},
signOutButton: {
marginTop: 50,
marginHorizontal: 10,
paddingVertical: 15,
backgroundColor: "transparent",
borderWidth: 1.3,
borderColor: "#fd1775",
},
signOut: {
fontFamily: "Poppins_500Medium",
fontSize: 15,
},
label: {
fontFamily: "Poppins_400Medium",
fontSize: 15,
},
title: {
fontSize: 26.13,
fontFamily: "Manrope_600SemiBold",
color: "#262627",
},
});
export default TabLayout;

View File

@ -50,15 +50,14 @@ import {Stack} from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import "react-native-reanimated";
import {AuthContextProvider} from "@/contexts/AuthContext";
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
import {TextProps, ThemeManager, Toast, Typography,} from "react-native-ui-lib";
import {Platform} from 'react-native';
import KeyboardManager from 'react-native-keyboard-manager';
import {enableScreens} from 'react-native-screens';
import {enableScreens, enableFreeze} from 'react-native-screens';
import {PersistQueryClientProvider} from "@/contexts/PersistQueryClientProvider";
import auth from "@react-native-firebase/auth";
enableScreens(true)
enableFreeze(true)
SplashScreen.preventAutoHideAsync();

View File

@ -20,7 +20,7 @@ const UsersList = () => {
const { user: currentUser } = useAuthContext();
const { data: familyMembers, refetch: refetchFamilyMembers } =
useGetFamilyMembers();
const [selectedUser, setSelectedUser] = useAtom<UserProfile | null>(selectedUserAtom);
const [selectedUser, setSelectedUser] = useAtom(selectedUserAtom);
useEffect(() => {
refetchFamilyMembers();

View File

@ -0,0 +1,27 @@
import React from 'react';
import {useAtomValue} from 'jotai';
import {selectedDateAtom} from '@/components/pages/calendar/atoms';
import {FlashList} from "@shopify/flash-list";
import {useDidUpdate} from "react-native-ui-lib/src/hooks";
interface CalendarControllerProps {
scrollViewRef: React.RefObject<FlashList<any>>;
centerMonthIndex: number;
}
export const CalendarController: React.FC<CalendarControllerProps> = (
{
scrollViewRef,
centerMonthIndex
}) => {
const selectedDate = useAtomValue(selectedDateAtom);
useDidUpdate(() => {
scrollViewRef.current?.scrollToIndex({
index: centerMonthIndex,
animated: false
})
}, [selectedDate, centerMonthIndex]);
return null;
};

View File

@ -1,121 +1,108 @@
import React, {memo, useState} from "react";
import React, {memo, useState, useMemo, useCallback} from "react";
import {StyleSheet} from "react-native";
import {Button, Picker, PickerModes, SegmentedControl, Text, View} from "react-native-ui-lib";
import {MaterialIcons} from "@expo/vector-icons";
import {months} from "./constants";
import {StyleSheet} from "react-native";
import {useAtom} from "jotai";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {format, isSameDay} from "date-fns";
import {format} from "date-fns";
import * as Device from "expo-device";
import {Mode} from "react-native-big-calendar";
import {useIsFetching} from "@tanstack/react-query";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {months} from "./constants";
import RefreshButton from "@/components/shared/RefreshButton";
import {useCalSync} from "@/hooks/useCalSync";
type ViewMode = "day" | "week" | "month" | "3days";
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const SEGMENTS = isTablet
? [{label: "D"}, {label: "W"}, {label: "M"}]
: [{label: "D"}, {label: "3D"}, {label: "M"}];
const MODE_MAP = {
tablet: ["day", "week", "month"],
mobile: ["day", "3days", "month"]
} as const;
export const CalendarHeader = memo(() => {
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const [mode, setMode] = useAtom(modeAtom);
const [tempIndex, setTempIndex] = useState<number | null>(null);
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const segments = isTablet
? [{label: "D"}, {label: "W"}, {label: "M"}]
: [{label: "D"}, {label: "3D"}, {label: "M"}];
const {resyncAllCalendars, isSyncing} = useCalSync();
const isFetching = useIsFetching({queryKey: ['events']}) > 0;
const handleSegmentChange = (index: number) => {
let selectedMode: Mode;
if (isTablet) {
selectedMode = ["day", "week", "month"][index] as Mode;
} else {
selectedMode = ["day", "3days", "month"][index] as Mode;
}
const isLoading = useMemo(() => isSyncing || isFetching, [isSyncing, isFetching]);
const handleSegmentChange = useCallback((index: number) => {
const modes = isTablet ? MODE_MAP.tablet : MODE_MAP.mobile;
const selectedMode = modes[index] as ViewMode;
setTempIndex(index);
setTimeout(() => {
setMode(selectedMode);
setTempIndex(null);
}, 150);
}, [setMode]);
if (selectedMode) {
setTimeout(() => {
setMode(selectedMode as "day" | "week" | "month" | "3days");
setTempIndex(null);
}, 150);
}
};
const handleMonthChange = (month: string) => {
const currentDay = selectedDate.getDate();
const currentYear = selectedDate.getFullYear();
const handleMonthChange = useCallback((month: string) => {
const newMonthIndex = months.indexOf(month);
const updatedDate = new Date(currentYear, newMonthIndex, currentDay);
const updatedDate = new Date(
selectedDate.getFullYear(),
newMonthIndex,
selectedDate.getDate()
);
setSelectedDate(updatedDate);
};
}, [selectedDate, setSelectedDate]);
const isSelectedDateToday = isSameDay(selectedDate, new Date());
const getInitialIndex = () => {
if (isTablet) {
switch (mode) {
case "day":
return 0;
case "week":
return 1;
case "month":
return 2;
default:
return 1;
}
} else {
switch (mode) {
case "day":
return 0;
case "3days":
return 1;
case "month":
return 2;
default:
return 1;
}
const handleRefresh = useCallback(async () => {
try {
await resyncAllCalendars();
} catch (error) {
console.error("Refresh failed:", error);
}
};
}, [resyncAllCalendars]);
const getInitialIndex = useCallback(() => {
const modes = isTablet ? MODE_MAP.tablet : MODE_MAP.mobile;
//@ts-ignore
return modes.indexOf(mode);
}, [mode]);
const renderMonthPicker = () => (
<View row centerV gap-1 flexS>
{isTablet && (
<Text style={styles.yearText}>
{selectedDate.getFullYear()}
</Text>
)}
<Picker
value={months[selectedDate.getMonth()]}
placeholder="Select Month"
style={styles.monthPicker}
mode={PickerModes.SINGLE}
onChange={value => handleMonthChange(value as string)}
trailingAccessory={<MaterialIcons name="keyboard-arrow-down"/>}
topBarProps={{
title: selectedDate.getFullYear().toString(),
titleStyle: styles.yearText,
}}
>
{months.map(month => (
<Picker.Item key={month} label={month} value={month}/>
))}
</Picker>
</View>
);
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 10,
paddingVertical: isTablet ? 8 : 0,
borderRadius: 20,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
backgroundColor: "white",
}}
centerV
>
<View row centerV gap-3>
{isTablet && (
<Text style={{fontFamily: "Manrope_500Medium", fontSize: 17}}>
{selectedDate.getFullYear()}
</Text>
)}
<Picker
value={months[selectedDate.getMonth()]}
placeholder={"Select Month"}
style={{fontFamily: "Manrope_500Medium", fontSize: 17, width: 85}}
mode={PickerModes.SINGLE}
onChange={(itemValue) => handleMonthChange(itemValue as string)}
trailingAccessory={<MaterialIcons name={"keyboard-arrow-down"}/>}
topBarProps={{
title: selectedDate.getFullYear().toString(),
titleStyle: {fontFamily: "Manrope_500Medium", fontSize: 17},
}}
>
{months.map((month) => (
<Picker.Item key={month} label={month} value={month}/>
))}
</Picker>
</View>
<View style={styles.container} flexG centerV>
{mode !== "month" ? renderMonthPicker() : <View flexG/>}
<View row centerV>
<View row centerV flexS>
<Button
size={"xSmall"}
size="xSmall"
marginR-1
avoidInnerPadding
style={styles.todayButton}
@ -124,27 +111,52 @@ export const CalendarHeader = memo(() => {
<MaterialIcons name="calendar-today" size={30} color="#5f6368"/>
<Text style={styles.todayDate}>{format(new Date(), "d")}</Text>
</Button>
<View>
<View style={styles.segmentContainer}>
<SegmentedControl
segments={segments}
segments={SEGMENTS}
backgroundColor="#ececec"
inactiveColor="#919191"
activeBackgroundColor="#ea156c"
activeColor="white"
outlineColor="white"
outlineWidth={3}
segmentLabelStyle={styles.segmentslblStyle}
segmentLabelStyle={styles.segmentLabel}
onChangeIndex={handleSegmentChange}
initialIndex={tempIndex ?? getInitialIndex()}
/>
</View>
<RefreshButton onRefresh={handleRefresh} isSyncing={isLoading}/>
</View>
</View>
);
});
const styles = StyleSheet.create({
segmentslblStyle: {
container: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: isTablet ? 8 : 0,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
paddingLeft: 10
},
yearText: {
fontFamily: "Manrope_500Medium",
fontSize: 17,
},
monthPicker: {
fontFamily: "Manrope_500Medium",
fontSize: 17,
width: 85,
},
segmentContainer: {
maxWidth: 120,
height: 40,
},
segmentLabel: {
fontSize: 12,
fontFamily: "Manrope_600SemiBold",
},

View File

@ -9,11 +9,6 @@ export default function CalendarPage() {
paddingH-0
paddingT-0
>
{/*<HeaderTemplate
message={"Let's get your week started !"}
isWelcome
isCalendar={true}
/>*/}
<InnerCalendar />
</View>
);

View File

@ -1,21 +1,20 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from "react";
import {useAuthContext} from "@/contexts/AuthContext";
import {useAtomValue} from "jotai";
import {modeAtom, selectedDateAtom, selectedUserAtom} from "@/components/pages/calendar/atoms";
import {useGetFamilyMembers} from "@/hooks/firebase/useGetFamilyMembers";
import React, {useCallback, useMemo, useRef, useState} from "react";
import {View} from "react-native-ui-lib";
import {DeviceType} from "expo-device";
import * as Device from "expo-device";
import {CalendarBody, CalendarContainer, CalendarHeader, CalendarKitHandle} from "@howljs/calendar-kit";
import {useAtomValue} from "jotai";
import {selectedUserAtom} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext";
import {useGetFamilyMembers} from "@/hooks/firebase/useGetFamilyMembers";
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
import {useFormattedEvents} from "@/components/pages/calendar/useFormattedEvents";
import {useCalendarControls} from "@/components/pages/calendar/useCalendarControls";
import {EventCell} from "@/components/pages/calendar/EventCell";
import {isToday} from "date-fns";
import {View} from "react-native-ui-lib";
import {DeviceType} from "expo-device";
import * as Device from "expo-device"
import {useAtomCallback} from 'jotai/utils'
import {DetailedCalendarController} from "@/components/pages/calendar/DetailedCalendarController";
interface EventCalendarProps {
calendarHeight: number;
calendarWidth: number;
mode: "week" | "month" | "day" | "3days";
onLoad?: () => void;
@ -36,14 +35,14 @@ const MODE_TO_DAYS = {
'3days': 3,
'day': 1,
'month': 1
};
} as const;
const getContainerProps = (selectedDate: Date) => ({
const getContainerProps = (date: Date, customKey: string) => ({
hourWidth: 70,
allowPinchToZoom: true,
useHaptic: true,
scrollToNow: true,
initialDate: selectedDate.toISOString(),
initialDate: customKey !== "default" ? customKey : date.toISOString(),
});
const MemoizedEventCell = React.memo(EventCell, (prev, next) => {
@ -51,37 +50,23 @@ const MemoizedEventCell = React.memo(EventCell, (prev, next) => {
prev.event.lastModified === next.event.lastModified;
});
export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
{
calendarHeight,
calendarWidth,
mode,
onLoad
}) => {
export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo(({
calendarWidth,
mode,
onLoad
}) => {
const {profileData} = useAuthContext();
const selectedDate = useAtomValue(selectedDateAtom);
const {data: familyMembers} = useGetFamilyMembers();
const calendarRef = useRef<CalendarKitHandle>(null);
const {data: events} = useGetEvents();
const selectedUser = useAtomValue(selectedUserAtom);
const calendarRef = useRef<CalendarKitHandle>(null);
const [customKey, setCustomKey] = useState("defaultKey");
const memoizedFamilyMembers = useMemo(() => familyMembers || [], [familyMembers]);
const containerProps = useMemo(() => getContainerProps(selectedDate), [selectedDate]);
const currentDate = useMemo(() => new Date(), []);
const containerProps = useMemo(() => getContainerProps(currentDate, customKey), [currentDate, customKey]);
const checkModeAndGoToDate = useAtomCallback(useCallback((get) => {
const currentMode = get(modeAtom);
if ((selectedDate && isToday(selectedDate)) || currentMode === "month") {
calendarRef?.current?.goToDate({date: selectedDate});
setCustomKey(selectedDate.toISOString())
}
}, [selectedDate]));
useEffect(() => {
checkModeAndGoToDate();
}, [selectedDate, checkModeAndGoToDate]);
const {data: formattedEvents} = useFormattedEvents(events ?? [], selectedDate, selectedUser);
const {data: formattedEvents} = useFormattedEvents(events ?? [], currentDate, selectedUser);
const {
handlePressEvent,
handlePressCell,
@ -115,9 +100,12 @@ export const DetailedCalendar: React.FC<EventCalendarProps> = React.memo((
onPressEvent={handlePressEvent}
onPressBackground={handlePressCell}
onLoad={onLoad}
key={customKey}
>
<CalendarHeader {...HEADER_PROPS} />
<DetailedCalendarController
calendarRef={calendarRef}
setCustomKey={setCustomKey}
/>
<CalendarHeader {...HEADER_PROPS}/>
<CalendarBody
{...BODY_PROPS}
renderEvent={renderEvent}

View File

@ -0,0 +1,38 @@
import React, {useCallback, useEffect, useRef} from 'react';
import {useAtomValue} from 'jotai';
import {useAtomCallback} from 'jotai/utils';
import {modeAtom, selectedDateAtom} from '@/components/pages/calendar/atoms';
import {isToday} from 'date-fns';
import {CalendarKitHandle} from "@howljs/calendar-kit";
interface DetailedCalendarDateControllerProps {
calendarRef: React.RefObject<CalendarKitHandle>;
setCustomKey: (key: string) => void;
}
export const DetailedCalendarController: React.FC<DetailedCalendarDateControllerProps> = ({
calendarRef,
setCustomKey
}) => {
const selectedDate = useAtomValue(selectedDateAtom);
const lastSelectedDate = useRef(selectedDate);
const checkModeAndGoToDate = useAtomCallback(useCallback((get) => {
const currentMode = get(modeAtom);
if ((selectedDate && isToday(selectedDate)) || currentMode === "month") {
if (currentMode === "month") {
setCustomKey(selectedDate.toISOString());
}
calendarRef?.current?.goToDate({date: selectedDate});
}
}, [selectedDate, calendarRef, setCustomKey]));
useEffect(() => {
if (selectedDate !== lastSelectedDate.current) {
checkModeAndGoToDate();
lastSelectedDate.current = selectedDate;
}
}, [selectedDate, checkModeAndGoToDate]);
return null;
};

View File

@ -1,33 +1,31 @@
import React from 'react';
import { StyleSheet, View, ActivityIndicator } from 'react-native';
import { Text } from 'react-native-ui-lib';
import {StyleSheet, View, ActivityIndicator} from 'react-native';
import {Text} from 'react-native-ui-lib';
import Animated, {
withTiming,
useAnimatedStyle,
FadeOut,
useSharedValue,
runOnJS
} from 'react-native-reanimated';
import { useGetEvents } from '@/hooks/firebase/useGetEvents';
import { useCalSync } from '@/hooks/useCalSync';
import { useSyncEvents } from '@/hooks/useSyncOnScroll';
import { useAtom } from 'jotai';
import { modeAtom } from './atoms';
import { MonthCalendar } from "@/components/pages/calendar/MonthCalendar";
import {useGetEvents} from '@/hooks/firebase/useGetEvents';
import {useCalSync} from '@/hooks/useCalSync';
import {useSyncEvents} from '@/hooks/useSyncOnScroll';
import {useAtom} from 'jotai';
import {modeAtom} from './atoms';
import {MonthCalendar} from "@/components/pages/calendar/MonthCalendar";
import DetailedCalendar from "@/components/pages/calendar/DetailedCalendar";
import * as Device from "expo-device";
export type CalendarMode = 'month' | 'day' | '3days' | 'week';
interface EventCalendarProps {
calendarHeight: number;
calendarWidth: number;
}
export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) => {
const { isLoading } = useGetEvents();
const {isLoading} = useGetEvents();
const [mode] = useAtom<CalendarMode>(modeAtom);
const { isSyncing } = useSyncEvents();
const {isSyncing} = useSyncEvents();
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const isCalendarReady = useSharedValue(false);
useCalSync();
@ -37,26 +35,26 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) =>
}, []);
const containerStyle = useAnimatedStyle(() => ({
opacity: withTiming(isCalendarReady.value ? 1 : 0, { duration: 500 }),
opacity: withTiming(isCalendarReady.value ? 1 : 0, {duration: 500}),
flex: 1,
}));
const monthStyle = useAnimatedStyle(() => ({
opacity: withTiming(mode === 'month' ? 1 : 0, { duration: 300 }),
opacity: withTiming(mode === 'month' ? 1 : 0, {duration: 300}),
position: 'absolute',
width: '100%',
height: '100%',
}));
const detailedDayStyle = useAnimatedStyle(() => ({
opacity: withTiming(mode === 'day' ? 1 : 0, { duration: 300 }),
opacity: withTiming(mode === 'day' ? 1 : 0, {duration: 300}),
position: 'absolute',
width: '100%',
height: '100%',
}));
const detailedMultiStyle = useAnimatedStyle(() => ({
opacity: withTiming(mode === (isTablet ? 'week' : '3days') ? 1 : 0, { duration: 300 }),
opacity: withTiming(mode === (isTablet ? 'week' : '3days') ? 1 : 0, {duration: 300}),
position: 'absolute',
width: '100%',
height: '100%',
@ -64,7 +62,7 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) =>
return (
<View style={styles.root}>
{(isLoading || isSyncing) && mode !== 'month' && (
{(isLoading || isSyncing) && mode !== 'month' && (
<Animated.View
exiting={FadeOut.duration(300)}
style={styles.loadingContainer}
@ -75,14 +73,19 @@ export const EventCalendar: React.FC<EventCalendarProps> = React.memo((props) =>
)}
<Animated.View style={containerStyle}>
<Animated.View style={monthStyle} pointerEvents={mode === 'month' ? 'auto' : 'none'}>
<MonthCalendar {...props} />
<MonthCalendar/>
</Animated.View>
<Animated.View style={detailedDayStyle} pointerEvents={mode === 'day' ? 'auto' : 'none'}>
<DetailedCalendar mode="day" {...props} />
</Animated.View>
<Animated.View style={detailedMultiStyle} pointerEvents={mode === (isTablet ? 'week' : '3days') ? 'auto' : 'none'}>
<Animated.View style={detailedMultiStyle}
pointerEvents={mode === (isTablet ? 'week' : '3days') ? 'auto' : 'none'}>
{!isLoading && (
<DetailedCalendar onLoad={handleRenderComplete} mode={isTablet ? 'week' : '3days'} {...props} />
<DetailedCalendar
onLoad={handleRenderComplete}
mode={isTablet ? 'week' : '3days'}
{...props}
/>
)}
</Animated.View>
</Animated.View>

View File

@ -4,19 +4,16 @@ import {LayoutChangeEvent} from "react-native";
import CalendarViewSwitch from "@/components/pages/calendar/CalendarViewSwitch";
import {AddEventDialog} from "@/components/pages/calendar/AddEventDialog";
import {ManuallyAddEventModal} from "@/components/pages/calendar/ManuallyAddEventModal";
import {CalendarHeader} from "@/components/pages/calendar/CalendarHeader";
import {EventCalendar} from "@/components/pages/calendar/EventCalendar";
export const InnerCalendar = () => {
const [calendarHeight, setCalendarHeight] = useState(0);
const [calendarWidth, setCalendarWidth] = useState(0);
const calendarContainerRef = useRef(null);
const hasSetInitialSize = useRef(false);
const onLayout = useCallback((event: LayoutChangeEvent) => {
if (!hasSetInitialSize.current) {
const {height, width} = event.nativeEvent.layout;
setCalendarHeight(height);
const width = event.nativeEvent.layout.width;
setCalendarWidth(width);
hasSetInitialSize.current = true;
}
@ -30,12 +27,9 @@ export const InnerCalendar = () => {
onLayout={onLayout}
paddingB-0
>
{calendarHeight > 0 && (
<EventCalendar
calendarHeight={calendarHeight}
calendarWidth={calendarWidth}
/>
)}
<EventCalendar
calendarWidth={calendarWidth}
/>
</View>
<CalendarViewSwitch/>

View File

@ -122,7 +122,7 @@ export const ManuallyAddEventModal = () => {
const {
mutateAsync: createEvent,
isLoading: isAdding,
isPending: isAdding,
isError,
} = useCreateEvent();
const {data: members} = useGetFamilyMembers(true);
@ -178,15 +178,6 @@ export const ManuallyAddEventModal = () => {
if (!show) return null;
const formatDateTime = (date?: Date | string) => {
if (!date) return undefined;
return new Date(date).toLocaleDateString("en-US", {
weekday: "long",
month: "short",
day: "numeric",
});
};
const showDeleteEventModal = () => {
setDeleteModalVisible(true);
};
@ -257,38 +248,6 @@ export const ManuallyAddEventModal = () => {
return true;
};
const getRepeatLabel = () => {
const selectedDays = repeatInterval;
const allDays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const workDays = ["monday", "tuesday", "wednesday", "thursday", "friday"];
const isEveryWorkDay = workDays.every((day) => selectedDays.includes(day));
const isEveryDay = allDays.every((day) => selectedDays.includes(day));
if (isEveryDay) {
return "Every day";
} else if (
isEveryWorkDay &&
!selectedDays.includes("saturday") &&
!selectedDays.includes("sunday")
) {
return "Every work day";
} else {
return selectedDays
.map((item) => daysOfWeek.find((day) => day.value === item)?.label)
.join(", ");
}
};
if (isLoading && !isError) {
return (
<Modal

View File

@ -1,30 +1,23 @@
import React, {useCallback, useEffect, useMemo, useRef, useState, useTransition} from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
NativeScrollEvent,
NativeSyntheticEvent,
} from 'react-native';
import React, {useCallback, useMemo, useRef} from 'react';
import {Dimensions, StyleSheet, Text, TouchableOpacity, View,} from 'react-native';
import {
addDays,
addMonths,
eachDayOfInterval,
endOfMonth,
format,
isSameDay,
isSameMonth,
isWithinInterval,
startOfMonth,
addMonths, startOfWeek, isWithinInterval,
} from 'date-fns';
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
import {useAtom, useSetAtom} from "jotai";
import {modeAtom, selectedDateAtom, selectedNewEventDateAtom} from "@/components/pages/calendar/atoms";
import {useSetAtom} from "jotai";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext";
import {FlashList} from "@shopify/flash-list";
import * as Device from "expo-device";
import debounce from "debounce";
import {CalendarController} from "@/components/pages/calendar/CalendarController";
interface CalendarEvent {
id: string;
@ -40,12 +33,12 @@ interface CustomMonthCalendarProps {
const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MAX_VISIBLE_EVENTS = 3;
const CENTER_MONTH_INDEX = 24;
const CENTER_MONTH_INDEX = 12;
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
<TouchableOpacity
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
style={[styles.event, {backgroundColor: event?.eventColor || '#6200ee'}]}
onPress={onPress}
>
<Text style={styles.eventText} numberOfLines={1}>
@ -78,7 +71,7 @@ const MultiDayEvent = React.memo(({
const style = {
position: 'absolute' as const,
height: 14,
backgroundColor: event.color || '#6200ee',
backgroundColor: event?.eventColor || '#6200ee',
padding: 2,
zIndex: 1,
left: isStart ? 4 : -0.5, // Extend slightly into the border
@ -103,22 +96,21 @@ const MultiDayEvent = React.memo(({
);
});
const Day = React.memo(({
date,
selectedDate,
events,
multiDayEvents,
dayWidth,
onPress
}: {
date: Date;
selectedDate: Date;
events: CalendarEvent[];
multiDayEvents: CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
}) => {
const isCurrentMonth = isSameMonth(date, selectedDate);
const Day = React.memo((
{
date,
events,
multiDayEvents,
dayWidth,
onPress
}: {
date: Date;
events: CalendarEvent[];
multiDayEvents: CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
}) => {
const isCurrentMonth = isSameMonth(date, new Date());
const isToday = isSameDay(date, new Date());
const remainingSlots = Math.max(0, MAX_VISIBLE_EVENTS - multiDayEvents.length);
@ -126,7 +118,6 @@ const Day = React.memo(({
const visibleSingleDayEvents = singleDayEvents.slice(0, remainingSlots);
const totalHiddenEvents = singleDayEvents.length - remainingSlots;
// Calculate space needed for multi-day events
const maxMultiDayPosition = multiDayEvents.length > 0
? Math.max(...multiDayEvents.map(e => e.weekPosition || 0)) + 1
: 0;
@ -140,7 +131,7 @@ const Day = React.memo(({
>
<View style={[
styles.dateContainer,
isToday && styles.todayContainer,
isToday && {backgroundColor: events?.[0]?.eventColor},
]}>
<Text style={[
styles.dateText,
@ -185,18 +176,9 @@ const Day = React.memo(({
);
});
const findFirstAvailablePosition = (usedPositions: number[]): number => {
let position = 0;
while (usedPositions.includes(position)) {
position++;
}
return position;
};
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const {data: rawEvents} = useGetEvents();
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const setSelectedDate = useSetAtom(selectedDateAtom);
const setMode = useSetAtom(modeAtom);
const {profileData} = useAuthContext();
@ -204,10 +186,8 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
const screenHeight = isTablet ? Dimensions.get('window').height * 0.89 : Dimensions.get('window').height;
const dayWidth = (screenWidth - 32) / 7;
const centerMonth = useRef(selectedDate);
const isScrolling = useRef(false);
const lastScrollUpdate = useRef<Date>(new Date());
const dayWidth = screenWidth / 7;
const centerMonth = useRef(new Date());
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
@ -255,10 +235,10 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
});
}
return months;
}, [getMonthData]);
}, [getMonthData, rawEvents]);
const processedEvents = useMemo(() => {
if (!rawEvents?.length || !selectedDate) return {
if (!rawEvents?.length) return {
eventMap: new Map(),
multiDayEvents: []
};
@ -324,7 +304,6 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
<Month
date={item.date}
days={item.days}
selectedDate={selectedDate}
getEventsForDay={getEventsForDay}
getMultiDayEventsForDay={getMultiDayEventsForDay}
dayWidth={dayWidth}
@ -334,31 +313,12 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
/>
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
const debouncedSetSelectedDate = useMemo(
() => debounce(setSelectedDate, 500),
[setSelectedDate]
);
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
if (isScrolling.current) return;
const currentMonthIndex = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
const currentMonth = monthsToRender[currentMonthIndex];
if (currentMonth) {
setSelectedDate(currentMonth.date);
centerMonth.current = currentMonth.date;
}
}, [screenWidth, setSelectedDate, monthsToRender]);
useEffect(() => {
return () => {
debouncedSetSelectedDate.clear();
};
}, [debouncedSetSelectedDate]);
return (
<View style={styles.container}>
<CalendarController
scrollViewRef={scrollViewRef}
centerMonthIndex={CENTER_MONTH_INDEX}
/>
<FlashList
ref={scrollViewRef}
data={monthsToRender}
@ -368,7 +328,6 @@ export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={CENTER_MONTH_INDEX}
onScroll={onScroll}
removeClippedSubviews={true}
estimatedItemSize={screenWidth}
estimatedListSize={{width: screenWidth, height: screenHeight * 0.9}}
@ -391,7 +350,6 @@ const keyExtractor = (item: MonthData, index: number) => `month-${index}`;
const Month = React.memo(({
date,
days,
selectedDate,
getEventsForDay,
getMultiDayEventsForDay,
dayWidth,
@ -401,7 +359,6 @@ const Month = React.memo(({
}: {
date: Date;
days: Date[];
selectedDate: Date;
getEventsForDay: (date: Date) => CalendarEvent[];
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
dayWidth: number;
@ -481,7 +438,6 @@ const Month = React.memo(({
<Day
key={`${weekIndex}-${dayIndex}`}
date={date}
selectedDate={selectedDate}
events={getEventsForDay(date)}
multiDayEvents={multiDayEvents}
dayWidth={dayWidth}
@ -529,11 +485,11 @@ const styles = StyleSheet.create({
},
day: {
height: '14%',
padding: 0, // Remove padding
padding: 0,
borderWidth: 0.5,
borderColor: '#eee',
position: 'relative',
overflow: 'visible', // Allow events to overflow
overflow: 'visible',
},
container: {
flex: 1,
@ -555,7 +511,7 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center'
// justifyContent: 'center'
},
weekDay: {
alignItems: 'center',
@ -609,6 +565,8 @@ const styles = StyleSheet.create({
weekDayRow: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 16,
paddingHorizontal: 0,
},
});
export default MonthCalendar;

View File

@ -46,7 +46,7 @@ const createEventHash = (event: FormattedEvent): string => {
// Precompute time constants
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const PERIOD_IN_MS = 5 * DAY_IN_MS;
const PERIOD_IN_MS = 180 * DAY_IN_MS;
const TIME_ZONE = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Memoize date range calculations

View File

@ -52,7 +52,7 @@ const AddChoreDialog = (addChoreDialogProps: IAddChoreDialog) => {
addChoreDialogProps.selectedTodo ?? defaultTodo
);
const [selectedAssignees, setSelectedAssignees] = useState<string[]>(
addChoreDialogProps?.selectedTodo?.assignees ?? [user?.uid]
addChoreDialogProps?.selectedTodo?.assignees ?? [user?.uid!]
);
const {width} = Dimensions.get("screen");
const [points, setPoints] = useState<number>(todo.points);

View File

@ -10,11 +10,11 @@ interface RefreshButtonProps {
}
const RefreshButton = ({
onRefresh,
isSyncing,
size = 24,
color = "#83807F"
}: RefreshButtonProps) => {
onRefresh,
isSyncing,
size = 24,
color = "#83807F"
}: RefreshButtonProps) => {
const rotateAnim = useRef(new Animated.Value(0)).current;
const rotationLoop = useRef<Animated.CompositeAnimation | null>(null);
@ -29,12 +29,12 @@ const RefreshButton = ({
const startContinuousRotation = () => {
rotateAnim.setValue(0);
rotationLoop.current = Animated.loop(
Animated.timing(rotateAnim, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
})
Animated.timing(rotateAnim, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
})
);
rotationLoop.current.start();
};
@ -56,11 +56,28 @@ const RefreshButton = ({
};
return (
<TouchableOpacity onPress={handlePress} disabled={isSyncing}>
<Animated.View style={{ transform: [{ rotate }] }}>
<Feather name="refresh-cw" size={size} color={color} />
</Animated.View>
</TouchableOpacity>
<TouchableOpacity
onPress={handlePress}
disabled={isSyncing}
style={{
width: size * 2,
height: size + 10,
justifyContent: 'center',
alignItems: 'center'
}}
>
<Animated.View
style={{
transform: [{ rotate }],
width: size,
height: size,
justifyContent: 'center',
alignItems: 'center'
}}
>
<Feather name="refresh-cw" size={size} color={color} />
</Animated.View>
</TouchableOpacity>
);
};

View File

@ -1780,26 +1780,35 @@ async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) {
});
let token = accessToken;
// Construct the Google Calendar event
const googleEvent = {
summary: event.title,
start: {
dateTime: event.allDay ? undefined : event.startDate.toISOString(),
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined,
timeZone: 'UTC'
},
end: {
dateTime: event.allDay ? undefined : event.endDate.toISOString(),
date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined
date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined,
timeZone: 'UTC'
},
visibility: event.private ? 'private' : 'default',
id: event.id
status: 'confirmed',
reminders: {
useDefault: true
},
// Add extendedProperties to store our Firestore ID
extendedProperties: {
private: {
firestoreId: event.id
}
}
};
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`;
// For new events, use POST to create
const url = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
let response = await fetch(url, {
method: 'PUT',
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
@ -1820,7 +1829,7 @@ async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) {
// Retry with new token
response = await fetch(url, {
method: 'PUT',
method: 'POST',
headers: {
'Authorization': `Bearer ${refreshedGoogleToken}`,
'Content-Type': 'application/json'
@ -1834,9 +1843,16 @@ async function syncEventToGoogle(event, accessToken, refreshToken, creatorId) {
throw new Error(`Failed to sync event: ${errorData.error?.message || response.statusText}`);
}
console.log('[GOOGLE_SYNC] Successfully synced event to Google Calendar', {
eventId: event.id,
creatorId
const responseData = await response.json();
// Store the Google Calendar event ID in Firestore
await db.collection('Events').doc(event.id).update({
googleEventId: responseData.id
});
console.log('[GOOGLE_SYNC] Successfully created event in Google Calendar', {
firestoreId: event.id,
googleEventId: responseData.id
});
return true;
@ -1900,282 +1916,353 @@ async function deleteEventFromGoogle(eventId, accessToken, refreshToken, creator
}
}
const createEventHash = (event) => {
const str = `${event.startDate?.seconds || ''}-${event.endDate?.seconds || ''}-${
event.title || ''
}-${event.location || ''}-${event.allDay ? 'true' : 'false'}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(36);
};
// Cloud Function to handle event updates
exports.syncEventToGoogleCalendar = functions.firestore
.document('Events/{eventId}')
.onWrite(async (change, context) => {
const eventId = context.params.eventId;
const afterData = change.after.exists ? change.after.data() : null;
const beforeData = change.before.exists ? change.before.data() : null;
async function fetchEventsFromFirestore(userId, profileData, isFamilyView) {
const db = admin.firestore();
const eventsQuery = db.collection("Events");
let constraints;
const familyId = profileData?.familyId;
// Skip if this is a Google-originated event
if (afterData?.externalOrigin === 'google' || beforeData?.externalOrigin === 'google') {
console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event', { eventId });
return null;
if (profileData?.userType === "FAMILY_DEVICE") {
constraints = [
eventsQuery.where("familyId", "==", familyId)
];
} else {
if (isFamilyView) {
constraints = [
eventsQuery.where("familyId", "==", familyId),
eventsQuery.where("creatorId", "==", userId),
eventsQuery.where("attendees", "array-contains", userId)
];
} else {
constraints = [
eventsQuery.where("creatorId", "==", userId),
eventsQuery.where("attendees", "array-contains", userId)
];
}
}
try {
const snapshots = await Promise.all(constraints.map(query => query.get()));
const uniqueEvents = new Map();
const processedHashes = new Set();
const creatorIds = new Set();
snapshots.forEach(snapshot => {
snapshot.docs.forEach(doc => {
const event = doc.data();
const hash = createEventHash(event);
if (!processedHashes.has(hash)) {
processedHashes.add(hash);
creatorIds.add(event.creatorId);
uniqueEvents.set(doc.id, event);
}
try {
// Handle deletion
if (!afterData && beforeData) {
console.log('[GOOGLE_SYNC] Processing event deletion', { eventId });
// Only proceed if this was previously synced with Google
if (!beforeData.email) {
return null;
}
const creatorDoc = await db.collection('Profiles').doc(beforeData.creatorId).get();
const creatorData = creatorDoc.data();
if (!creatorData?.googleAccounts?.[beforeData.email]) {
return null;
}
const accountData = creatorData.googleAccounts[beforeData.email];
await deleteEventFromGoogle(
eventId,
accountData.accessToken,
accountData.refreshToken,
beforeData.creatorId,
beforeData.email
);
return null;
}
// Handle creation or update
if (afterData) {
// Skip if no creator or email is set
if (!afterData.creatorId || !afterData.email) {
return null;
}
const creatorDoc = await db.collection('Profiles').doc(afterData.creatorId).get();
const creatorData = creatorDoc.data();
if (!creatorData?.googleAccounts?.[afterData.email]) {
return null;
}
const accountData = creatorData.googleAccounts[afterData.email];
await syncEventToGoogle(
afterData,
accountData.accessToken,
accountData.refreshToken,
afterData.creatorId
);
}
return null;
} catch (error) {
console.error('[GOOGLE_SYNC] Error in sync function:', error);
// Store the error for later retry or monitoring
await db.collection('SyncErrors').add({
eventId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
type: 'google'
});
throw error;
}
}););
let token = accessToken;
// Construct the Google Calendar event
const googleEvent = {
summary: event.title,
start: {
dateTime: event.allDay ? undefined : event.startDate.toISOString(),
date: event.allDay ? event.startDate.toISOString().split('T')[0] : undefined
},
end: {
dateTime: event.allDay ? undefined : event.endDate.toISOString(),
date: event.allDay ? new Date(event.endDate.getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined
},
visibility: event.private ? 'private' : 'default',
id: event.id
};
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${event.id}`;
let response = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(googleEvent)
});
});
// Handle token refresh if needed
if (response.status === 401 && refreshToken) {
console.log('[GOOGLE_SYNC] Token expired, refreshing...');
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
token = refreshedGoogleToken;
const creatorIdsArray = Array.from(creatorIds);
const creatorProfiles = new Map();
const BATCH_SIZE = 10;
// Update the token in Firestore
await db.collection("Profiles").doc(creatorId).update({
[`googleAccounts.${event.email}.accessToken`]: refreshedGoogleToken
for (let i = 0; i < creatorIdsArray.length; i += BATCH_SIZE) {
const chunk = creatorIdsArray.slice(i, i + BATCH_SIZE);
const profilesSnapshot = await db
.collection("Profiles")
.where(admin.firestore.FieldPath.documentId(), "in", chunk)
.get();
profilesSnapshot.docs.forEach(doc => {
creatorProfiles.set(doc.id, doc.data()?.eventColor || '#ff69b4');
});
}
// Retry with new token
response = await fetch(url, {
method: 'PUT',
return Array.from(uniqueEvents.entries()).map(([id, event]) => ({
...event,
id,
start: event.allDay
? new Date(new Date(event.startDate.seconds * 1000).setHours(0, 0, 0, 0))
: new Date(event.startDate.seconds * 1000),
end: event.allDay
? new Date(new Date(event.endDate.seconds * 1000).setHours(0, 0, 0, 0))
: new Date(event.endDate.seconds * 1000),
hideHours: event.allDay,
eventColor: creatorProfiles.get(event.creatorId) || '#ff69b4',
notes: event.notes
}));
} catch (error) {
console.error('Error fetching events:', error);
throw new functions.https.HttpsError('internal', 'Error fetching events');
}
}
exports.fetchEvents = functions.https.onCall(async (data, context) => {
if (!context.auth) {
throw new functions.https.HttpsError(
'unauthenticated',
'User must be authenticated'
);
}
try {
const { isFamilyView } = data;
const userId = context.auth.uid;
const profileDoc = await admin.firestore()
.collection('Profiles')
.doc(userId)
.get();
if (!profileDoc.exists) {
throw new functions.https.HttpsError(
'not-found',
'User profile not found'
);
}
const profileData = profileDoc.data();
const events = await fetchEventsFromFirestore(userId, profileData, isFamilyView);
return { events };
} catch (error) {
console.error('Error in fetchEvents:', error);
throw new functions.https.HttpsError(
'internal',
error.message || 'An unknown error occurred'
);
}
});
exports.syncNewEventToGoogle = functions.firestore
.document('Events/{eventId}')
.onCreate(async (snapshot, context) => {
const newEvent = snapshot.data();
const eventId = context.params.eventId;
// Don't sync if this event came from Google
if (newEvent.externalOrigin === 'google') {
console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event:', eventId);
return;
}
try {
// Get the creator's Google account credentials
const creatorDoc = await db.collection('Profiles').doc(newEvent.creatorId).get();
const creatorData = creatorDoc.data();
if (!creatorData?.googleAccounts) {
console.log('[GOOGLE_SYNC] Creator has no Google accounts:', newEvent.creatorId);
return;
}
// Get the first Google account (assuming one account per user)
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
if (!accountData?.accessToken) {
console.log('[GOOGLE_SYNC] No access token found for creator:', newEvent.creatorId);
return;
}
await syncEventToGoogle(
{
...newEvent,
email,
startDate: new Date(newEvent.startDate.seconds * 1000),
endDate: new Date(newEvent.endDate.seconds * 1000)
},
accountData.accessToken,
accountData.refreshToken,
newEvent.creatorId
);
console.log('[GOOGLE_SYNC] Successfully synced new event to Google:', eventId);
} catch (error) {
console.error('[GOOGLE_SYNC] Error syncing new event to Google:', error);
}
});
exports.syncEventToGoogleOnUpdate = functions.firestore
.document('Events/{eventId}')
.onUpdate(async (change, context) => {
const eventBefore = change.before.data();
const eventAfter = change.after.data();
const eventId = context.params.eventId;
if (eventAfter.externalOrigin === 'google') {
console.log('[GOOGLE_SYNC] Skipping sync for Google-originated event:', eventId);
return;
}
if (JSON.stringify(eventBefore) === JSON.stringify(eventAfter)) {
console.log('[GOOGLE_SYNC] No changes detected for event:', eventId);
return;
}
try {
const creatorDoc = await db.collection('Profiles').doc(eventAfter.creatorId).get();
const creatorData = creatorDoc.data();
if (!creatorData?.googleAccounts) {
console.log('[GOOGLE_SYNC] Creator has no Google accounts:', eventAfter.creatorId);
return;
}
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
if (!accountData?.accessToken) {
console.log('[GOOGLE_SYNC] No access token found for creator:', eventAfter.creatorId);
return;
}
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
const googleEvent = {
summary: eventAfter.title,
start: {
dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.startDate.seconds * 1000).toISOString(),
date: eventAfter.allDay ? new Date(eventAfter.startDate.seconds * 1000).toISOString().split('T')[0] : undefined,
timeZone: 'UTC'
},
end: {
dateTime: eventAfter.allDay ? undefined : new Date(eventAfter.endDate.seconds * 1000).toISOString(),
date: eventAfter.allDay ? new Date(new Date(eventAfter.endDate.seconds * 1000).getTime() + 24*60*60*1000).toISOString().split('T')[0] : undefined,
timeZone: 'UTC'
},
visibility: eventAfter.private ? 'private' : 'default',
status: 'confirmed',
reminders: {
useDefault: true
}
};
let response = await fetch(url, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${refreshedGoogleToken}`,
'Authorization': `Bearer ${accountData.accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(googleEvent)
});
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(`Failed to sync event: ${errorData.error?.message || response.statusText}`);
}
if (response.status === 401 && accountData.refreshToken) {
console.log('[GOOGLE_SYNC] Token expired, refreshing...');
const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken);
await db.collection("Profiles").doc(eventAfter.creatorId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
console.log('[GOOGLE_SYNC] Successfully synced event to Google Calendar', {
eventId: event.id,
creatorId
});
return true;
} catch (error) {
console.error('[GOOGLE_SYNC] Error syncing event to Google Calendar:', error);
throw error;
}
}
async function deleteEventFromGoogle(eventId, accessToken, refreshToken, creatorId, email) {
try {
console.log('[GOOGLE_DELETE] Starting to delete event from Google Calendar', {
eventId,
creatorId
});
let token = accessToken;
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
let response = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
response = await fetch(url, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${refreshedGoogleToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(googleEvent)
});
}
});
// Handle token refresh if needed
if (response.status === 401 && refreshToken) {
console.log('[GOOGLE_DELETE] Token expired, refreshing...');
const { refreshedGoogleToken } = await refreshGoogleToken(refreshToken);
token = refreshedGoogleToken;
// If event doesn't exist in Google Calendar, create it using insert
if (response.status === 404) {
console.log('[GOOGLE_SYNC] Event not found in Google Calendar, creating new event');
const insertUrl = 'https://www.googleapis.com/calendar/v3/calendars/primary/events';
response = await fetch(insertUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accountData.accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
...googleEvent,
id: eventId
})
});
}
// Update the token in Firestore
await db.collection("Profiles").doc(creatorId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || response.statusText);
}
// Retry with new token
response = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${refreshedGoogleToken}`
}
});
console.log('[GOOGLE_SYNC] Successfully synced event update to Google:', eventId);
} catch (error) {
console.error('[GOOGLE_SYNC] Error syncing event update to Google:', error);
}
});
if (!response.ok && response.status !== 404) {
const errorData = await response.json();
throw new Error(`Failed to delete event: ${errorData.error?.message || response.statusText}`);
}
console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', {
eventId,
creatorId
});
return true;
} catch (error) {
console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error);
throw error;
}
}
exports.handleEventDelete = functions.firestore
exports.syncEventToGoogleOnDelete = functions.firestore
.document('Events/{eventId}')
.onDelete(async (snapshot, context) => {
const deletedEvent = snapshot.data();
const eventId = context.params.eventId;
// Skip if this was a Google-originated event to prevent sync loops
if (deletedEvent?.externalOrigin === 'google') {
console.log('[GOOGLE_DELETE] Skipping delete sync for Google-originated event', { eventId });
return null;
if (deletedEvent.externalOrigin === 'google') {
console.log('[GOOGLE_SYNC] Skipping delete sync for Google-originated event:', eventId);
return;
}
try {
// Only proceed if this was synced with Google (has an email)
if (!deletedEvent?.email) {
console.log('[GOOGLE_DELETE] Event not synced with Google, skipping', { eventId });
return null;
}
const creatorDoc = await admin.firestore()
.collection('Profiles')
.doc(deletedEvent.creatorId)
.get();
if (!creatorDoc.exists) {
console.log('[GOOGLE_DELETE] Creator profile not found', {
eventId,
creatorId: deletedEvent.creatorId
});
return null;
}
const creatorDoc = await db.collection('Profiles').doc(deletedEvent.creatorId).get();
const creatorData = creatorDoc.data();
const googleAccount = creatorData?.googleAccounts?.[deletedEvent.email];
if (!googleAccount) {
console.log('[GOOGLE_DELETE] No Google account found for email', {
eventId,
email: deletedEvent.email
});
return null;
if (!creatorData?.googleAccounts) {
console.log('[GOOGLE_SYNC] Creator has no Google accounts:', deletedEvent.creatorId);
return;
}
await deleteEventFromGoogle(
eventId,
googleAccount.accessToken,
googleAccount.refreshToken,
deletedEvent.creatorId,
deletedEvent.email
);
const [email, accountData] = Object.entries(creatorData.googleAccounts)[0];
console.log('[GOOGLE_DELETE] Successfully deleted event from Google Calendar', {
eventId,
email: deletedEvent.email
if (!accountData?.accessToken) {
console.log('[GOOGLE_SYNC] No access token found for creator:', deletedEvent.creatorId);
return;
}
const url = `https://www.googleapis.com/calendar/v3/calendars/primary/events/${eventId}`;
let response = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accountData.accessToken}`
}
});
} catch (error) {
console.error('[GOOGLE_DELETE] Error deleting event from Google Calendar:', error);
// Store the error for monitoring
await admin.firestore()
.collection('SyncErrors')
.add({
eventId,
error: error.message,
timestamp: admin.firestore.FieldValue.serverTimestamp(),
type: 'google_delete'
if (response.status === 401 && accountData.refreshToken) {
console.log('[GOOGLE_SYNC] Token expired, refreshing...');
const { refreshedGoogleToken } = await refreshGoogleToken(accountData.refreshToken);
await db.collection("Profiles").doc(deletedEvent.creatorId).update({
[`googleAccounts.${email}.accessToken`]: refreshedGoogleToken
});
throw error;
response = await fetch(url, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${refreshedGoogleToken}`
}
});
}
if (!response.ok && response.status !== 404) {
const errorData = await response.json();
throw new Error(errorData.error?.message || response.statusText);
}
console.log('[GOOGLE_SYNC] Successfully deleted event from Google:', eventId);
} catch (error) {
console.error('[GOOGLE_SYNC] Error deleting event from Google:', error);
}
});
exports.sendOverviews = functions.pubsub

4001
ios/Podfile.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -90,7 +90,6 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.5",
"react-native-big-calendar": "^4.15.1",
"react-native-calendars": "^1.1306.0",
"react-native-element-dropdown": "^2.12.2",
"react-native-gesture-handler": "~2.20.2",

View File

@ -0,0 +1,148 @@
diff --git a/node_modules/@howljs/calendar-kit/src/hooks/useSyncedList.tsx b/node_modules/@howljs/calendar-kit/src/hooks/useSyncedList.tsx
index 2c2d340..c27d395 100644
--- a/node_modules/@howljs/calendar-kit/src/hooks/useSyncedList.tsx
+++ b/node_modules/@howljs/calendar-kit/src/hooks/useSyncedList.tsx
@@ -40,12 +40,12 @@ const useSyncedList = ({ id }: { id: ScrollType }) => {
const _onMomentumEnd = () => {
if (
- isTriggerMomentum.current &&
- startDateUnix.current !== visibleDateUnix.current
+ isTriggerMomentum.current &&
+ startDateUnix.current !== visibleDateUnix.current
) {
triggerDateChanged.current = undefined;
onDateChanged?.(
- dateTimeToISOString(parseDateTime(visibleDateUnix.current))
+ dateTimeToISOString(parseDateTime(visibleDateUnix.current))
);
notifyDateChanged(visibleDateUnix.current);
isTriggerMomentum.current = false;
@@ -83,71 +83,70 @@ const useSyncedList = ({ id }: { id: ScrollType }) => {
});
const onVisibleColumnChanged = useCallback(
- (props: {
- index: number;
- column: number;
- columns: number;
- offset: number;
- extraScrollData: Record<string, any>;
- }) => {
- const { index: pageIndex, column, columns, extraScrollData } = props;
- const { visibleColumns, visibleDates } = extraScrollData;
+ (props: {
+ index: number;
+ column: number;
+ columns: number;
+ offset: number;
+ extraScrollData: Record<string, any>;
+ }) => {
+ const { index: pageIndex, column, columns, extraScrollData } = props;
+ const { visibleColumns, visibleDates } = extraScrollData;
- if (scrollType.current === id && visibleColumns && visibleDates) {
- const dayIndex = pageIndex * columns + column;
- const visibleStart = visibleDates[pageIndex * columns];
- const visibleEnd =
- visibleDates[pageIndex * columns + column + visibleColumns];
+ if (scrollType.current === id && visibleColumns && visibleDates) {
+ const dayIndex = pageIndex * columns + column;
+ const visibleStart = visibleDates[pageIndex * columns];
+ const visibleEnd =
+ visibleDates[pageIndex * columns + column + visibleColumns];
- if (visibleStart && visibleEnd) {
- const diffDays = Math.floor(
- (visibleEnd - visibleStart) / MILLISECONDS_IN_DAY
- );
- if (diffDays <= 7) {
- visibleWeeks.value = [visibleStart];
- } else {
- const nextWeekStart = visibleDates[pageIndex * columns + 7];
- if (nextWeekStart) {
- visibleWeeks.value = [visibleStart, nextWeekStart];
+ if (visibleStart && visibleEnd) {
+ const diffDays = Math.floor(
+ (visibleEnd - visibleStart) / MILLISECONDS_IN_DAY
+ );
+ if (diffDays <= 7) {
+ visibleWeeks.value = [visibleStart];
+ } else {
+ const nextWeekStart = visibleDates[pageIndex * columns + 7];
+ if (nextWeekStart) {
+ visibleWeeks.value = [visibleStart, nextWeekStart];
+ }
}
}
- }
-
- const currentDate = visibleDates[dayIndex];
- if (!currentDate) {
- triggerDateChanged.current = undefined;
- return;
- }
- if (visibleDateUnix.current !== currentDate) {
- const dateIsoStr = dateTimeToISOString(parseDateTime(currentDate));
- onChange?.(dateIsoStr);
- if (
- triggerDateChanged.current &&
- triggerDateChanged.current === currentDate
- ) {
+ const currentDate = visibleDates[dayIndex];
+ if (!currentDate) {
triggerDateChanged.current = undefined;
- onDateChanged?.(dateIsoStr);
- notifyDateChanged(currentDate);
+ return;
+ }
+
+ if (visibleDateUnix.current !== currentDate) {
+ const dateIsoStr = dateTimeToISOString(parseDateTime(currentDate));
+ onChange?.(dateIsoStr);
+ if (
+ triggerDateChanged?.current === currentDate
+ ) {
+ triggerDateChanged.current = undefined;
+ onDateChanged?.(dateIsoStr);
+ notifyDateChanged(currentDate);
+ }
+ visibleDateUnix.current = currentDate;
+ runOnUI(() => {
+ visibleDateUnixAnim.value = currentDate;
+ })();
}
- visibleDateUnix.current = currentDate;
- runOnUI(() => {
- visibleDateUnixAnim.value = currentDate;
- })();
}
- }
- },
- [
- scrollType,
- id,
- visibleDateUnix,
- visibleWeeks,
- triggerDateChanged,
- onChange,
- onDateChanged,
- notifyDateChanged,
- visibleDateUnixAnim,
- ]
+ },
+ [
+ scrollType,
+ id,
+ visibleDateUnix,
+ visibleWeeks,
+ triggerDateChanged,
+ onChange,
+ onDateChanged,
+ notifyDateChanged,
+ visibleDateUnixAnim,
+ ]
);
return { onScroll, onVisibleColumnChanged };

View File

@ -1,195 +0,0 @@
diff --git a/node_modules/react-native-big-calendar/build/index.js b/node_modules/react-native-big-calendar/build/index.js
index 848ceba..f326b8e 100644
--- a/node_modules/react-native-big-calendar/build/index.js
+++ b/node_modules/react-native-big-calendar/build/index.js
@@ -9,6 +9,17 @@ var isoWeek = require('dayjs/plugin/isoWeek');
var React = require('react');
var reactNative = require('react-native');
var calendarize = require('calendarize');
+var {
+ startOfDay,
+ endOfDay,
+ startOfWeek,
+ isAfter,
+ isBefore,
+ isSameDay,
+ differenceInDays,
+ add,
+ getTime
+} = require('date-fns');
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
@@ -1000,87 +1011,91 @@ function _CalendarBodyForMonthView(_a) {
var start = _a.start, end = _a.end;
return day.isBetween(dayjs__default["default"](start).startOf('day'), dayjs__default["default"](end).endOf('day'), null, '[)');
});
- }
- else {
- /**
- * Better way to sort overlapping events that spans accross multiple days
- * For example, if you want following events
- * Event 1, start = 01/01 12:00, end = 02/01 12:00
- * Event 2, start = 02/01 12:00, end = 03/01 12:00
- * Event 3, start = 03/01 12:00, end = 04/01 12:00
- *
- * When drawing calendar in month view, event 3 should be placed at 3rd index for 03/01, because Event 2 are placed at 2nd index for 02/01 and 03/01
- *
- */
- var min_1 = day.startOf('day'), max_1 = day.endOf('day');
- //filter all events that starts from the current week until the current day, and sort them by reverse starting time
- var filteredEvents_1 = events
- .filter(function (_a) {
- var start = _a.start, end = _a.end;
- return dayjs__default["default"](end).isAfter(day.startOf('week')) && dayjs__default["default"](start).isBefore(max_1);
- })
- .sort(function (a, b) {
- if (dayjs__default["default"](a.start).isSame(b.start, 'day')) {
- var aDuration = dayjs__default["default"].duration(dayjs__default["default"](a.end).diff(dayjs__default["default"](a.start))).days();
- var bDuration = dayjs__default["default"].duration(dayjs__default["default"](b.end).diff(dayjs__default["default"](b.start))).days();
- return aDuration - bDuration;
- }
- return b.start.getTime() - a.start.getTime();
+ } else {
+ // Convert day once and cache all the commonly used dates/times
+ const jsDay = day?.toDate?.() || day;
+ const weekStart = startOfWeek(jsDay);
+ var min_1 = startOfDay(jsDay);
+ var max_1 = endOfDay(jsDay);
+ const max1Time = getTime(max_1);
+ const min1Time = getTime(min_1);
+
+ // Pre-process events with dates and cache timestamps
+ const processedEvents = events.map(event => {
+ const startDay = event.start?.toDate?.() || new Date(event.start);
+ const endDay = event.end?.toDate?.() || new Date(event.end);
+ return {
+ ...event,
+ startDay,
+ endDay,
+ startTime: getTime(startDay),
+ endTime: getTime(endDay),
+ startDayStart: startOfDay(startDay)
+ };
});
- /**
- * find the most relevant min date to filter the events
- * in the example:
- * 1. when rendering for 01/01, min date will be 01/01 (start of day for event 1)
- * 2. when rendering for 02/01, min date will be 01/01 (start of day for event 1)
- * 3. when rendering for 03/01, min date will be 01/01 (start of day for event 1)
- * 4. when rendering for 04/01, min date will be 01/01 (start of day for event 1)
- * 5. when rendering for 05/01, min date will be 05/01 (no event overlaps with 05/01)
- */
- filteredEvents_1.forEach(function (_a) {
- var start = _a.start, end = _a.end;
- if (dayjs__default["default"](end).isAfter(min_1) && dayjs__default["default"](start).isBefore(min_1)) {
- min_1 = dayjs__default["default"](start).startOf('day');
+
+ // Filter events within the weekly range and sort by reverse start time
+ let filteredEvents_1 = processedEvents
+ .filter(({ startTime, endTime }) =>
+ endTime > getTime(weekStart) && startTime < max1Time
+ )
+ .sort((a, b) => {
+ if (isSameDay(a.startDay, b.startDay)) {
+ // Pre-calculate durations since they're used in sorting
+ const aDuration = differenceInDays(a.endDay, a.startDay);
+ const bDuration = differenceInDays(b.endDay, b.startDay);
+ return bDuration - aDuration;
+ }
+ return b.startTime - a.startTime;
+ });
+
+ // Update min_1 to the earliest startDay for overlapping events
+ for (const event of filteredEvents_1) {
+ if (event.endTime > min1Time && event.startTime < min1Time) {
+ min_1 = event.startDayStart;
+ break; // We only need the first one due to the sort order
}
- });
+ }
+
+ // Filter to keep only events that overlap the min to max range, then reverse
+ const min1TimeUpdated = getTime(min_1);
filteredEvents_1 = filteredEvents_1
- .filter(function (_a) {
- var start = _a.start, end = _a.end;
- return dayjs__default["default"](end).endOf('day').isAfter(min_1) && dayjs__default["default"](start).isBefore(max_1);
- })
+ .filter(({ startTime, endDay }) =>
+ getTime(endOfDay(endDay)) > min1TimeUpdated && startTime < max1Time
+ )
.reverse();
- /**
- * We move eligible event to the top
- * For example, when rendering for 03/01, Event 3 should be moved to the top, since there is a gap left by Event 1
- */
- var finalEvents_1 = [];
- var tmpDay_1 = day.startOf('week');
- //re-sort events from the start of week until the calendar cell date
- //optimize sorting of event nodes and make sure that no empty gaps are left on top of calendar cell
- while (!tmpDay_1.isAfter(day)) {
- filteredEvents_1.forEach(function (event) {
- if (dayjs__default["default"](event.end).isBefore(tmpDay_1.startOf('day'))) {
- var eventToMoveUp = filteredEvents_1.find(function (e) {
- return dayjs__default["default"](e.start).startOf('day').isSame(tmpDay_1.startOf('day'));
- });
- if (eventToMoveUp != undefined) {
- //remove eventToMoveUp from finalEvents first
- if (finalEvents_1.indexOf(eventToMoveUp) > -1) {
- finalEvents_1.splice(finalEvents_1.indexOf(eventToMoveUp), 1);
- }
- if (finalEvents_1.indexOf(event) > -1) {
- finalEvents_1.splice(finalEvents_1.indexOf(event), 1, eventToMoveUp);
- }
- else {
+
+ // Move eligible events to the top, preventing duplicate entries
+ const finalEvents_1 = [];
+ const seenEvents = new Set(); // Use Set for faster lookups
+ let tmpDay_1 = weekStart;
+
+ while (!isAfter(tmpDay_1, jsDay)) {
+ const tmpDayTime = getTime(tmpDay_1);
+
+ for (const event of filteredEvents_1) {
+ const eventEndDayTime = getTime(startOfDay(event.endDay));
+
+ if (!seenEvents.has(event)) {
+ if (eventEndDayTime < tmpDayTime) {
+ // Find event starting on tmpDay
+ const eventToMoveUp = filteredEvents_1.find(e =>
+ isSameDay(e.startDayStart, tmpDay_1)
+ );
+ if (eventToMoveUp && !seenEvents.has(eventToMoveUp)) {
finalEvents_1.push(eventToMoveUp);
+ seenEvents.add(eventToMoveUp);
}
+ } else {
+ finalEvents_1.push(event);
+ seenEvents.add(event);
}
}
- else if (finalEvents_1.indexOf(event) == -1) {
- finalEvents_1.push(event);
- }
- });
- tmpDay_1 = tmpDay_1.add(1, 'day');
+ }
+ tmpDay_1 = add(tmpDay_1, { days: 1 });
}
+
+ console.log(finalEvents_1);
return finalEvents_1;
}
}, [events, sortedMonthView]);
@@ -1311,7 +1326,7 @@ function _CalendarHeader(_a) {
!stringHasContent(dayHeaderHighlightColor) &&
u['mt-6'],
] }, date.format('D')))),
- showAllDayEventCell ? (React__namespace.createElement(reactNative.View, { style: [
+ showAllDayEventCell ? (React__namespace.createElement(reactNative.ScrollView, { style: [
u['border-l'],
{ borderColor: theme.palette.gray['200'] },
{ height: cellHeight },

View File

@ -4394,11 +4394,6 @@ cacache@^18.0.2:
tar "^6.1.11"
unique-filename "^3.0.0"
calendarize@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/calendarize/-/calendarize-1.1.1.tgz#0fa8b8de6b5e6ff9f9fbb89cc3a8c01aace291c2"
integrity sha512-C2JyBAtNp2NG4DX4fA1EILggLt/5PlYzvQR0crHktoAPBc9TlIfdhzg7tWekCbe+pH6+9qoK+FhPbi+vYJJlqw==
call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
@ -5005,7 +5000,7 @@ date-fns@^3.6.0:
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf"
integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==
dayjs@^1.11.13, dayjs@^1.8.15:
dayjs@^1.8.15:
version "1.11.13"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c"
integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==
@ -9618,14 +9613,6 @@ react-is@^17.0.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
react-native-big-calendar@^4.15.1:
version "4.15.1"
resolved "https://registry.yarnpkg.com/react-native-big-calendar/-/react-native-big-calendar-4.15.1.tgz#9cfef290e40a51cbc59b1be4d065804b21f2f74f"
integrity sha512-hNrzkM+9Kb2T0J/1fW9AMaeN+AuhakCfNtQPaQL29l3JXgOO14ikJ3iPqQkmNVbuiWYiMrpI25hrmXffiOVIgQ==
dependencies:
calendarize "^1.1.1"
dayjs "^1.11.13"
react-native-calendars@^1.1306.0:
version "1.1307.0"
resolved "https://registry.yarnpkg.com/react-native-calendars/-/react-native-calendars-1.1307.0.tgz#2cb8c5abd322037e9b3ef5605afa86f25347068f"