From 87137e7b1578a76bbc21de54f7d3d3a0f5e87d49 Mon Sep 17 00:00:00 2001 From: Milan Paunovic Date: Fri, 1 Nov 2024 22:02:36 +0100 Subject: [PATCH] All day event package --- calendar-integration/google-calendar-utils.js | 88 +++++++----- components/pages/calendar/CalendarHeader.tsx | 1 + components/pages/calendar/CalendarPage.tsx | 2 +- components/pages/calendar/EventCalendar.tsx | 113 +++++++++------ firebase/functions/index.js | 2 +- hooks/firebase/useGetEvents.ts | 4 +- hooks/useCalSync.ts | 16 +-- hooks/useFetchAndSaveAppleEvents.ts | 11 +- hooks/useFetchAndSaveGoogleEvents.ts | 135 ++++++++++++------ hooks/useFetchAndSaveOutlookEvents.ts | 9 +- hooks/useSyncOnScroll.ts | 85 +++++++++++ .../react-native-big-calendar+4.15.1.patch | 2 +- 12 files changed, 322 insertions(+), 146 deletions(-) create mode 100644 hooks/useSyncOnScroll.ts diff --git a/calendar-integration/google-calendar-utils.js b/calendar-integration/google-calendar-utils.js index d95d279..59709c5 100644 --- a/calendar-integration/google-calendar-utils.js +++ b/calendar-integration/google-calendar-utils.js @@ -1,50 +1,64 @@ export async function fetchGoogleCalendarEvents(token, email, familyId, startDate, endDate) { - const response = await fetch( - `https://www.googleapis.com/calendar/v3/calendars/primary/events?single_events=true&time_min=${startDate}&time_max=${endDate}`, - { + const googleEvents = []; + let pageToken = null; + + do { + const url = new URL(`https://www.googleapis.com/calendar/v3/calendars/primary/events`); + url.searchParams.set('singleEvents', 'true'); + url.searchParams.set('timeMin', startDate); + url.searchParams.set('timeMax', endDate); + if (pageToken) url.searchParams.set('pageToken', pageToken); + + const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${token}`, }, - }, - ); + }); - const data = await response.json(); - const googleEvents = []; + const data = await response.json(); - data.items?.forEach((item) => { - let isAllDay = false; - let startDateTime, endDateTime; - - if (item.start) { - if (item.start.dateTime) { - startDateTime = new Date(item.start.dateTime); - } else if (item.start.date) { - startDateTime = new Date(item.start.date); - isAllDay = true; - } + if (!response.ok) { + throw new Error(`Error fetching events: ${data.error?.message || response.statusText}`); } - if (item.end) { - if (item.end.dateTime) { - endDateTime = new Date(item.end.dateTime); - } else if (item.end.date) { - endDateTime = new Date(item.end.date); - isAllDay = true; + data.items?.forEach((item) => { + let isAllDay = false; + let startDateTime, endDateTime; + + if (item.start) { + if (item.start.dateTime) { + startDateTime = new Date(item.start.dateTime); + } else if (item.start.date) { + startDateTime = new Date(item.start.date); + isAllDay = true; + } } - } - const googleEvent = { - id: item.id, - title: item.summary || "", - startDate: startDateTime, - endDate: endDateTime, - allDay: isAllDay, - familyId, - email, - }; + if (item.end) { + if (item.end.dateTime) { + endDateTime = new Date(item.end.dateTime); + } else if (item.end.date) { + endDateTime = new Date(item.end.date); + isAllDay = true; + } + } - googleEvents.push(googleEvent); - }); + const googleEvent = { + id: item.id, + title: item.summary || "", + startDate: startDateTime, + endDate: endDateTime, + allDay: isAllDay, + familyId, + email, + }; - return {googleEvents, success: response.ok}; + googleEvents.push(googleEvent); + }); + + // Prepare for the next page if it exists + pageToken = data.nextPageToken; + } while (pageToken); + + return { googleEvents, success: true }; } \ No newline at end of file diff --git a/components/pages/calendar/CalendarHeader.tsx b/components/pages/calendar/CalendarHeader.tsx index 403e63f..c77de27 100644 --- a/components/pages/calendar/CalendarHeader.tsx +++ b/components/pages/calendar/CalendarHeader.tsx @@ -14,6 +14,7 @@ import { useAtom } from "jotai"; import { modeAtom, selectedDateAtom } from "@/components/pages/calendar/atoms"; import { format, isSameDay } from "date-fns"; import { useAuthContext } from "@/contexts/AuthContext"; +import {useIsMutating} from "react-query"; export const CalendarHeader = memo(() => { const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); diff --git a/components/pages/calendar/CalendarPage.tsx b/components/pages/calendar/CalendarPage.tsx index eddd7d5..0118f6c 100644 --- a/components/pages/calendar/CalendarPage.tsx +++ b/components/pages/calendar/CalendarPage.tsx @@ -11,7 +11,7 @@ export default function CalendarPage() { paddingT-0 > diff --git a/components/pages/calendar/EventCalendar.tsx b/components/pages/calendar/EventCalendar.tsx index 0336d5b..cd7248e 100644 --- a/components/pages/calendar/EventCalendar.tsx +++ b/components/pages/calendar/EventCalendar.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useMemo, useState} from "react"; import {Calendar} from "react-native-big-calendar"; -import {ActivityIndicator, StyleSheet, View, ViewStyle} from "react-native"; +import {ActivityIndicator, ScrollView, StyleSheet, View, ViewStyle} from "react-native"; import {useGetEvents} from "@/hooks/firebase/useGetEvents"; import {useAtom, useSetAtom} from "jotai"; import { @@ -14,6 +14,9 @@ import {useAuthContext} from "@/contexts/AuthContext"; import {CalendarEvent} from "@/components/pages/calendar/interfaces"; import {Text} from "react-native-ui-lib"; import {addDays, compareAsc, isWithinInterval, subDays} from "date-fns"; +import {useCalSync} from "@/hooks/useCalSync"; +import { useIsMutating } from "react-query"; +import {useSyncEvents} from "@/hooks/useSyncOnScroll"; interface EventCalendarProps { calendarHeight: number; @@ -37,6 +40,7 @@ export const EventCalendar: React.FC = React.memo( const setEventForEdit = useSetAtom(eventForEditAtom); const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); + const {isSyncing} = useSyncEvents() const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes()); const todaysDate = new Date(); @@ -75,7 +79,7 @@ export const EventCalendar: React.FC = React.memo( ); const memoizedEventCellStyle = useCallback( - (event: CalendarEvent) => ({backgroundColor: event.eventColor}), + (event: CalendarEvent) => ({backgroundColor: event.eventColor, fontSize: 14}), [] ); @@ -211,6 +215,7 @@ export const EventCalendar: React.FC = React.memo( if (isLoading) { return ( + {isSyncing && Syncing...} ); @@ -219,52 +224,65 @@ export const EventCalendar: React.FC = React.memo( // console.log(enrichedEvents, filteredEvents) return ( - + {isSyncing && ( + + {isSyncing && Syncing...} + + + )} + + }} + dayHeaderStyle={dateStyle} + dayHeaderHighlightColor={"white"} + showAdjacentMonths + // headerContainerStyle={mode !== "month" ? { + // overflow:"hidden", + // height: 12, + // } : {}} + hourStyle={styles.hourStyle} + ampm + // renderCustomDateForMonth={renderCustomDateForMonth} + /> + + ); } ); @@ -291,6 +309,11 @@ const styles = StyleSheet.create({ flex: 1, justifyContent: "center", alignItems: "center", + position: "absolute", + width: "100%", + height: "100%", + zIndex: 100, + backgroundColor: "rgba(255, 255, 255, 0.9)", }, dayHeader: { backgroundColor: "#4184f2", diff --git a/firebase/functions/index.js b/firebase/functions/index.js index 704507b..a524e50 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -181,7 +181,7 @@ exports.generateCustomToken = onRequest(async (request, response) => { } }); -exports.refreshTokens = functions.pubsub.schedule('every 1 hours').onRun(async (context) => { +exports.refreshTokens = functions.pubsub.schedule('every 45 minutes').onRun(async (context) => { console.log('Running token refresh job...'); const profilesSnapshot = await db.collection('Profiles').get(); diff --git a/hooks/firebase/useGetEvents.ts b/hooks/firebase/useGetEvents.ts index 3b7254a..65924fd 100644 --- a/hooks/firebase/useGetEvents.ts +++ b/hooks/firebase/useGetEvents.ts @@ -20,6 +20,7 @@ export const useGetEvents = () => { // If family view is active, include family, creator, and attendee events if (isFamilyView) { + const familyQuery = db.collection("Events").where("familyID", "==", familyId); const creatorQuery = db.collection("Events").where("creatorId", "==", userId); const attendeeQuery = db.collection("Events").where("attendees", "array-contains", userId); @@ -83,7 +84,7 @@ export const useGetEvents = () => { const eventColor = profileData?.eventColor || colorMap.pink; return { - id: event.id || Math.random().toString(36).substr(2, 9), // Generate temp ID if missing + id: event.id || Math.random().toString(36).slice(2, 9), // Generate temp ID if missing title: event.title, start: new Date(event.startDate.seconds * 1000), end: new Date(event.endDate.seconds * 1000), @@ -96,5 +97,6 @@ export const useGetEvents = () => { }, staleTime: Infinity, cacheTime: Infinity, + keepPreviousData: true, }); }; \ No newline at end of file diff --git a/hooks/useCalSync.ts b/hooks/useCalSync.ts index 76c2dff..20af736 100644 --- a/hooks/useCalSync.ts +++ b/hooks/useCalSync.ts @@ -46,14 +46,9 @@ export const useCalSync = () => { const {profileData} = useAuthContext(); const {mutateAsync: updateUserData} = useUpdateUserData(); - const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = - useFetchAndSaveGoogleEvents(); - const { - mutateAsync: fetchAndSaveOutlookEvents, - isLoading: isSyncingOutlook, - } = useFetchAndSaveOutlookEvents(); - const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = - useFetchAndSaveAppleEvents(); + const {mutateAsync: fetchAndSaveGoogleEvents, isLoading: isSyncingGoogle} = useFetchAndSaveGoogleEvents(); + const {mutateAsync: fetchAndSaveOutlookEvents, isLoading: isSyncingOutlook} = useFetchAndSaveOutlookEvents(); + const {mutateAsync: fetchAndSaveAppleEvents, isLoading: isSyncingApple} = useFetchAndSaveAppleEvents(); WebBrowser.maybeCompleteAuthSession(); const [_, response, promptAsync] = Google.useAuthRequest(googleConfig); @@ -74,6 +69,7 @@ export const useCalSync = () => { } ); + console.log(response) const userInfo = await userInfoResponse.json(); const googleMail = userInfo.email; @@ -89,6 +85,7 @@ export const useCalSync = () => { await fetchAndSaveGoogleEvents({ token: accessToken, + refreshToken: refreshToken, email: googleMail, }); } @@ -283,6 +280,7 @@ export const useCalSync = () => { isConnectedToGoogle, isSyncingOutlook, isSyncingGoogle, - isSyncingApple + isSyncingApple, + isSyncing: isSyncingApple || isSyncingOutlook || isSyncingGoogle } } \ No newline at end of file diff --git a/hooks/useFetchAndSaveAppleEvents.ts b/hooks/useFetchAndSaveAppleEvents.ts index 85f1cf7..a18276b 100644 --- a/hooks/useFetchAndSaveAppleEvents.ts +++ b/hooks/useFetchAndSaveAppleEvents.ts @@ -9,11 +9,12 @@ export const useFetchAndSaveAppleEvents = () => { const {mutateAsync: createEventsFromProvider} = useCreateEventsFromProvider(); return useMutation({ - mutationKey: ["fetchAndSaveAppleEvents"], - mutationFn: async ({token, email}: { token?: string, email?: string }) => { - console.log("CALLL") - const timeMin = new Date(new Date().setFullYear(new Date().getFullYear() - 1)); - const timeMax = new Date(new Date().setFullYear(new Date().getFullYear() + 5)); + mutationKey: ["fetchAndSaveAppleEvents", "sync"], + mutationFn: async ({token, email, date}: { token?: string; email?: string, date?: Date }) => { + const baseDate = date || new Date(); + const timeMin = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() - 1)); + const timeMax = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() + 1)); + try { const response = await fetchiPhoneCalendarEvents( profileData?.familyId!, diff --git a/hooks/useFetchAndSaveGoogleEvents.ts b/hooks/useFetchAndSaveGoogleEvents.ts index 55b0e7a..0b944d6 100644 --- a/hooks/useFetchAndSaveGoogleEvents.ts +++ b/hooks/useFetchAndSaveGoogleEvents.ts @@ -1,56 +1,107 @@ -import {useMutation, useQueryClient} from "react-query"; -import {fetchGoogleCalendarEvents} from "@/calendar-integration/google-calendar-utils"; -import {useAuthContext} from "@/contexts/AuthContext"; -import {useCreateEventsFromProvider} from "@/hooks/firebase/useCreateEvent"; -import {useClearTokens} from "@/hooks/firebase/useClearTokens"; +import { useMutation, useQueryClient } from "react-query"; +import { fetchGoogleCalendarEvents } from "@/calendar-integration/google-calendar-utils"; +import { useAuthContext } from "@/contexts/AuthContext"; +import { useCreateEventsFromProvider } from "@/hooks/firebase/useCreateEvent"; +import { useClearTokens } from "@/hooks/firebase/useClearTokens"; +import { useUpdateUserData } from "@/hooks/firebase/useUpdateUserData"; export const useFetchAndSaveGoogleEvents = () => { - const queryClient = useQueryClient() - const {profileData} = useAuthContext(); - const {mutateAsync: createEventsFromProvider} = useCreateEventsFromProvider(); - const {mutateAsync: clearToken} = useClearTokens(); + const queryClient = useQueryClient(); + const { profileData } = useAuthContext(); + const { mutateAsync: createEventsFromProvider } = useCreateEventsFromProvider(); + const { mutateAsync: clearToken } = useClearTokens(); + const { mutateAsync: updateUserData } = useUpdateUserData(); return useMutation({ - mutationKey: ["fetchAndSaveGoogleEvents"], - mutationFn: async ({token, email}: { token?: string; email?: string }) => { - console.log("Fetching Google Calendar events..."); - const timeMin = new Date(new Date().setMonth(new Date().getMonth() - 2)); - const timeMax = new Date(new Date().setMonth(new Date().getMonth() + 2)); + mutationKey: ["fetchAndSaveGoogleEvents", "sync"], + mutationFn: async ({ token, email, date, refreshToken }: { token?: string; refreshToken?: string; email?: string; date?: Date }) => { + const baseDate = date || new Date(); + const timeMin = new Date(baseDate.setMonth(baseDate.getMonth() - 1)).toISOString().slice(0, -5) + "Z"; + const timeMax = new Date(baseDate.setMonth(baseDate.getMonth() + 2)).toISOString().slice(0, -5) + "Z"; console.log("Token: ", token); - try { - const response = await fetchGoogleCalendarEvents( - token, - email, - profileData?.familyId, - timeMin.toISOString().slice(0, -5) + "Z", - timeMax.toISOString().slice(0, -5) + "Z" - ); + const tryFetchEvents = async (isRetry = false) => { + try { + const response = await fetchGoogleCalendarEvents( + token, + email, + profileData?.familyId, + timeMin, + timeMax + ); - if(!response.success) { - await clearToken({email: email!, provider: "google"}) - return - } - - console.log("Google Calendar events fetched:", response); - - const items = response?.googleEvents?.map((item) => { - if (item.allDay) { - item.startDate = new Date(new Date(item.startDate).setHours(0, 0, 0, 0)); - item.endDate = item.startDate; + if (!response.success) { + await clearToken({ email: email!, provider: "google" }); + return; // Stop refetching if clearing the token } - return item; - }) || []; - await createEventsFromProvider(items); - } catch (error) { - console.error("Error fetching Google Calendar events:", error); - throw error; // Ensure errors are propagated to the mutation - } + console.log("Google Calendar events fetched:", response); + + const items = response?.googleEvents?.map((item) => { + if (item.allDay) { + item.startDate = new Date(item.startDate.setHours(0, 0, 0, 0)); + item.endDate = item.startDate; + } + return item; + }) || []; + + await createEventsFromProvider(items); + } catch (error) { + console.error("Error fetching Google Calendar events:", error); + + if (!isRetry) { + const refreshedToken = await handleRefreshToken(email, refreshToken); + if (refreshedToken) { + await updateUserData({ + newUserData: { + googleAccounts: { + ...profileData.googleAccounts, + [email!]: { ...profileData.googleAccounts[email!], accessToken: refreshedToken }, + }, + }, + }); + return tryFetchEvents(true); // Retry once after refreshing + } else { + await clearToken({ email: email!, provider: "google" }); + console.error(`Token refresh failed; token cleared for ${email}`); + throw error; + } + } else { + console.error(`Retry failed after refreshing token for user ${profileData?.email}:`, error.message); + throw error; + } + } + }; + + return tryFetchEvents(); }, onSuccess: () => { - queryClient.invalidateQueries(["events"]) + queryClient.invalidateQueries(["events"]); }, }); -}; \ No newline at end of file +}; + +async function handleRefreshToken(email: string, refreshToken: string) { + if (!refreshToken) return null; + + try { + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: "406146460310-2u67ab2nbhu23trp8auho1fq4om29fc0.apps.googleusercontent.com", + }), + }); + + const data = await response.json(); + return data.access_token; + } catch (error) { + console.error("Error refreshing Google token:", error); + return null; + } +} \ No newline at end of file diff --git a/hooks/useFetchAndSaveOutlookEvents.ts b/hooks/useFetchAndSaveOutlookEvents.ts index 5d5f6a3..23dfdbf 100644 --- a/hooks/useFetchAndSaveOutlookEvents.ts +++ b/hooks/useFetchAndSaveOutlookEvents.ts @@ -9,10 +9,11 @@ export const useFetchAndSaveOutlookEvents = () => { const {mutateAsync: createEventsFromProvider} = useCreateEventsFromProvider(); return useMutation({ - mutationKey: ["fetchAndSaveOutlookEvents"], - mutationFn: async ({token, email}: { token?: string; email?: string }) => { - const timeMin = new Date(new Date().setFullYear(new Date().getFullYear() - 1)); - const timeMax = new Date(new Date().setFullYear(new Date().getFullYear() + 3)); + mutationKey: ["fetchAndSaveOutlookEvents", "sync"], + mutationFn: async ({token, email, date}: { token?: string; email?: string, date?: Date }) => { + const baseDate = date || new Date(); + const timeMin = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() - 1)); + const timeMax = new Date(new Date(baseDate).setFullYear(new Date(baseDate).getMonth() + 1)); console.log("Token: ", token ?? profileData?.microsoftToken); diff --git a/hooks/useSyncOnScroll.ts b/hooks/useSyncOnScroll.ts new file mode 100644 index 0000000..0377197 --- /dev/null +++ b/hooks/useSyncOnScroll.ts @@ -0,0 +1,85 @@ +import { useState, useEffect, useCallback } from "react"; +import { useAuthContext } from "@/contexts/AuthContext"; +import { useAtomValue } from "jotai"; +import { useFetchAndSaveGoogleEvents } from "./useFetchAndSaveGoogleEvents"; +import { useFetchAndSaveAppleEvents } from "./useFetchAndSaveAppleEvents"; +import { useFetchAndSaveOutlookEvents } from "./useFetchAndSaveOutlookEvents"; +import { selectedDateAtom } from "@/components/pages/calendar/atoms"; +import { addDays, subDays, isBefore, isAfter, format } from "date-fns"; + +export const useSyncEvents = () => { + const { profileData } = useAuthContext(); + const selectedDate = useAtomValue(selectedDateAtom); + + const [lastSyncDate, setLastSyncDate] = useState(selectedDate); + const [lowerBoundDate, setLowerBoundDate] = useState(subDays(selectedDate, 6 * 30)); + const [upperBoundDate, setUpperBoundDate] = useState(addDays(selectedDate, 6 * 30)); + const [isSyncing, setIsSyncing] = useState(false); + const [error, setError] = useState(null); + + const syncedRanges = useState>(new Set())[0]; + + const { mutateAsync: fetchAndSaveGoogleEvents } = useFetchAndSaveGoogleEvents(); + const { mutateAsync: fetchAndSaveOutlookEvents } = useFetchAndSaveOutlookEvents(); + const { mutateAsync: fetchAndSaveAppleEvents } = useFetchAndSaveAppleEvents(); + + const generateRangeKey = (startDate: Date, endDate: Date) => { + return `${format(startDate, "yyyy-MM-dd")}_${format(endDate, "yyyy-MM-dd")}`; + }; + + const syncEvents = useCallback(async () => { + setIsSyncing(true); + setError(null); + + const newLowerBound = subDays(selectedDate, 6 * 30); + const newUpperBound = addDays(selectedDate, 6 * 30); + const rangeKey = generateRangeKey(newLowerBound, newUpperBound); + + if (syncedRanges.has(rangeKey)) { + setIsSyncing(false); + return; + } + + if (isBefore(selectedDate, lowerBoundDate) || isAfter(selectedDate, upperBoundDate)) { + try { + const googleEvents = Object.entries(profileData?.googleAccounts || {}).map(([email, { accessToken }]) => + fetchAndSaveGoogleEvents({ token: accessToken, email, date: selectedDate }) + ); + + const outlookEvents = Object.entries(profileData?.microsoftAccounts || {}).map(([email, token]) => + fetchAndSaveOutlookEvents({ token, email, date: selectedDate }) + ); + + const appleEvents = Object.entries(profileData?.appleAccounts || {}).map(([email, token]) => + fetchAndSaveAppleEvents({ token, email, date: selectedDate }) + ); + + await Promise.all([...googleEvents, ...outlookEvents, ...appleEvents]); + + setLastSyncDate(selectedDate); + setLowerBoundDate(newLowerBound); + setUpperBoundDate(newUpperBound); + syncedRanges.add(rangeKey); + } catch (err) { + console.error("Error syncing events:", err); + setError(err); + } finally { + setIsSyncing(false); + } + } else { + setIsSyncing(false); + } + }, [selectedDate, lowerBoundDate, upperBoundDate, profileData, fetchAndSaveGoogleEvents, fetchAndSaveOutlookEvents, fetchAndSaveAppleEvents, syncedRanges]); + + useEffect(() => { + syncEvents(); + }, [selectedDate, syncEvents]); + + return { + isSyncing, + error, + lastSyncDate, + lowerBoundDate, + upperBoundDate, + }; +}; \ No newline at end of file diff --git a/patches/react-native-big-calendar+4.15.1.patch b/patches/react-native-big-calendar+4.15.1.patch index 9173bde..88c2906 100644 --- a/patches/react-native-big-calendar+4.15.1.patch +++ b/patches/react-native-big-calendar+4.15.1.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/react-native-big-calendar/build/index.js b/node_modules/react-native-big-calendar/build/index.js -index 848ceba..57fbaed 100644 +index 848ceba..add1b1c 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');