diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx index 5994627..6e8df2a 100644 --- a/app/(auth)/_layout.tsx +++ b/app/(auth)/_layout.tsx @@ -1,15 +1,18 @@ -import React, {useCallback} from "react"; +import React, {memo, useCallback, useMemo} from "react"; import {Drawer} from "expo-router/drawer"; -import {DrawerContentScrollView, DrawerContentComponentProps, DrawerNavigationOptions} from "@react-navigation/drawer"; +import { + DrawerContentComponentProps, + DrawerContentScrollView, + DrawerNavigationOptions, + DrawerNavigationProp +} 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 {DeviceType} 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 {ParamListBase, RouteProp, Theme} from '@react-navigation/native'; import {useSignOut} from "@/hooks/firebase/useSignOut"; import {CalendarHeader} from "@/components/pages/calendar/CalendarHeader"; @@ -205,7 +208,7 @@ interface HeaderRightProps { navigation: DrawerScreenNavigationProp; } -const HeaderRight: React.FC = ({route, navigation}) => { +const HeaderRight: React.FC = memo(({route, navigation}) => { const showViewSwitch = ["calendar", "todos", "index"].includes(route.name); const isCalendarPage = ["calendar", "index"].includes(route.name); @@ -222,43 +225,7 @@ const HeaderRight: React.FC = ({route, navigation}) => { )} - ); -}; - -const screenOptions: (props: { - route: RouteProp; - navigation: DrawerNavigationProp; - theme: Theme; -}) => DrawerNavigationOptions = ({route, navigation}) => ({ - lazy: true, - headerShown: true, - headerTitleAlign: "left", - headerTitle: ({children}) => { - const isCalendarRoute = ["calendar", "index"].includes(route.name); - if (isCalendarRoute) return null; - - return ( - - - {children} - - - ); - }, - headerLeft: () => ( - navigation.toggleDrawer()} - hitSlop={{top: 10, bottom: 10, left: 10, right: 10}} - style={({pressed}) => [styles.drawerTrigger, {opacity: pressed ? 0.4 : 1}]} - > - - - ), - headerRight: () => } - navigation={navigation as DrawerNavigationProp} - />, - drawerStyle: styles.drawer, + ) }); interface DrawerScreen { @@ -280,6 +247,45 @@ const DRAWER_SCREENS: DrawerScreen[] = [ ]; const TabLayout: React.FC = () => { + const screenOptions = useMemo(() => { + return ({route, navigation}: { + route: RouteProp; + navigation: DrawerNavigationProp; + theme: Theme; + }): DrawerNavigationOptions => ({ + lazy: true, + headerShown: true, + headerTitleAlign: "left", + headerTitle: ({children}) => { + const isCalendarRoute = ["calendar", "index"].includes(route.name); + if (isCalendarRoute) return null; + + return ( + + + {children} + + + ); + }, + headerLeft: () => ( + navigation.toggleDrawer()} + hitSlop={{top: 10, bottom: 10, left: 10, right: 10}} + style={({pressed}) => [styles.drawerTrigger, {opacity: pressed ? 0.4 : 1}]} + > + + + ), + headerRight: () => } + navigation={navigation as DrawerNavigationProp} + />, + drawerStyle: styles.drawer, + }); + }, []); + + return ( ; } -const ViewSwitch = memo(function ViewSwitch({ navigation }: ViewSwitchProps) { +const ViewSwitch = memo(function ViewSwitch({navigation}: ViewSwitchProps) { const currentIndex = useNavigationState((state) => state.index === 6 ? 1 : 0); + const isInitialMount = useRef(true); + const navigationPending = useRef(false); + + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + }, []); const handleSegmentChange = useCallback( (index: number) => { - if (index === currentIndex) return; - navigation.navigate(index === 0 ? "calendar" : "todos"); + if (navigationPending.current) return; + + navigationPending.current = true; + setTimeout(() => { + navigation.navigate(index === 0 ? "calendar" : "todos"); + navigationPending.current = false; + }, 300); }, - [navigation, currentIndex] + [navigation] ); + const segments = useMemo(() => [ + {label: "Calendar", segmentLabelStyle: styles.labelStyle}, + {label: "To Dos", segmentLabelStyle: styles.labelStyle}, + ], []); + return ( { }, [mode]); const renderMonthPicker = () => ( - - {isTablet && ( - - {selectedDate.getFullYear()} - - )} - handleMonthChange(value as string)} - trailingAccessory={} - topBarProps={{ - title: selectedDate.getFullYear().toString(), - titleStyle: styles.yearText, - }} - > - {months.map(month => ( - - ))} - - + <> + {isTablet && } + + {isTablet && ( + + {selectedDate.getFullYear()} + + )} + handleMonthChange(value as string)} + trailingAccessory={} + topBarProps={{ + title: selectedDate.getFullYear().toString(), + titleStyle: styles.yearText, + }} + > + {months.map(month => ( + + ))} + + + ); return ( diff --git a/firebase/functions/index.js b/firebase/functions/index.js index bec2b4b..f2eb865 100644 --- a/firebase/functions/index.js +++ b/firebase/functions/index.js @@ -308,59 +308,296 @@ async function fetchAndSaveMicrosoftEvents({ token, refreshToken, email, familyI } } -// Check Upcoming Events every 5 minutes – send reminders -exports.checkUpcomingEvents = functions.pubsub - .schedule("every 5 minutes") +exports.sendOverviews = functions.pubsub + .schedule('0 20 * * *') // Runs at 8 PM daily .onRun(async (context) => { - const now = Timestamp.now(); - const tenMinutesFromNow = new Date(now.toDate().getTime() + 10 * 60 * 1000); + const familiesSnapshot = await admin.firestore().collection('Families').get(); - const eventsSnapshot = await db.collection("Events") + for (const familyDoc of familiesSnapshot.docs) { + const familyId = familyDoc.id; + const familySettings = familyDoc.data()?.settings || {}; + const overviewTime = familySettings.overviewTime || '20:00'; + + const [hours, minutes] = overviewTime.split(':'); + const now = new Date(); + if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) { + continue; + } + + try { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const tomorrowEnd = new Date(tomorrow); + tomorrowEnd.setHours(23, 59, 59, 999); + + const weekEnd = new Date(tomorrow); + weekEnd.setDate(weekEnd.getDate() + 7); + weekEnd.setHours(23, 59, 59, 999); + + const [tomorrowEvents, weekEvents] = await Promise.all([ + admin.firestore() + .collection('Events') + .where('familyId', '==', familyId) + .where('startDate', '>=', tomorrow) + .where('startDate', '<=', tomorrowEnd) + .orderBy('startDate') + .limit(3) + .get(), + + admin.firestore() + .collection('Events') + .where('familyId', '==', familyId) + .where('startDate', '>', tomorrowEnd) + .where('startDate', '<=', weekEnd) + .orderBy('startDate') + .limit(3) + .get() + ]); + + if (tomorrowEvents.empty && weekEvents.empty) { + continue; + } + + let notificationBody = ''; + + if (!tomorrowEvents.empty) { + notificationBody += 'Tomorrow: '; + const tomorrowTitles = tomorrowEvents.docs.map(doc => doc.data().title); + notificationBody += tomorrowTitles.join(', '); + if (tomorrowEvents.size === 3) notificationBody += ' and more...'; + } + + if (!weekEvents.empty) { + if (notificationBody) notificationBody += '\n\n'; + notificationBody += 'This week: '; + const weekTitles = weekEvents.docs.map(doc => doc.data().title); + notificationBody += weekTitles.join(', '); + if (weekEvents.size === 3) notificationBody += ' and more...'; + } + + const pushTokens = await getPushTokensForFamily(familyId); + await sendNotifications(pushTokens, { + title: "Family Calendar Overview", + body: notificationBody, + data: { + type: 'calendar_overview', + date: tomorrow.toISOString() + } + }); + + await storeNotification({ + type: 'calendar_overview', + familyId, + content: notificationBody, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + }); + + } catch (error) { + console.error(`Error sending overview for family ${familyId}:`, error); + } + } + }); + +exports.sendWeeklyOverview = functions.pubsub + .schedule('0 20 * * 0') // Runs at 8 PM every Sunday + .onRun(async (context) => { + const familiesSnapshot = await admin.firestore().collection('Families').get(); + + for (const familyDoc of familiesSnapshot.docs) { + const familyId = familyDoc.id; + const familySettings = familyDoc.data()?.settings || {}; + const overviewTime = familySettings.weeklyOverviewTime || '20:00'; + + const [hours, minutes] = overviewTime.split(':'); + const now = new Date(); + if (now.getHours() !== parseInt(hours) || now.getMinutes() !== parseInt(minutes)) { + continue; + } + + try { + const weekStart = new Date(); + weekStart.setDate(weekStart.getDate() + 1); + weekStart.setHours(0, 0, 0, 0); + + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 7); + + const weekEvents = await admin.firestore() + .collection('Events') + .where('familyId', '==', familyId) + .where('startDate', '>=', weekStart) + .where('startDate', '<=', weekEnd) + .orderBy('startDate') + .limit(3) + .get(); + + if (weekEvents.empty) continue; + + const eventTitles = weekEvents.docs.map(doc => doc.data().title); + const notificationBody = `Next week: ${eventTitles.join(', ')}${weekEvents.size === 3 ? ' and more...' : ''}`; + + const pushTokens = await getPushTokensForFamily(familyId); + await sendNotifications(pushTokens, { + title: "Weekly Calendar Overview", + body: notificationBody, + data: { + type: 'weekly_overview', + weekStart: weekStart.toISOString() + } + }); + + await storeNotification({ + type: 'weekly_overview', + familyId, + content: notificationBody, + timestamp: admin.firestore.FieldValue.serverTimestamp() + }); + + } catch (error) { + console.error(`Error sending weekly overview for family ${familyId}:`, error); + } + } + }); + +exports.checkUpcomingEvents = functions.pubsub + .schedule("every 1 minutes") + .onRun(async (context) => { + const now = admin.firestore.Timestamp.now(); + const oneHourFromNow = new Date(now.toDate().getTime() + 60 * 60 * 1000); + + logger.info("Checking upcoming events", { + currentTime: now.toDate().toISOString(), + lookAheadTime: oneHourFromNow.toISOString() + }); + + const eventsSnapshot = await admin.firestore() + .collection("Events") .where("startDate", ">=", now) - .where("startDate", "<=", Timestamp.fromDate(tenMinutesFromNow)) + .where("startDate", "<=", admin.firestore.Timestamp.fromDate(oneHourFromNow)) .get(); - await Promise.all(eventsSnapshot.docs.map(async (doc) => { + logger.info(`Found ${eventsSnapshot.size} upcoming events to check`); + + const processPromises = eventsSnapshot.docs.map(async (doc) => { const event = doc.data(); - if (!event?.startDate) return; - const { familyId, title, allDay } = event; + const eventId = doc.id; + + // Skip if reminder already sent + if (event.reminderSent === true || + event.notifiedAt || + (event.reminderSentAt && + now.toMillis() - event.reminderSentAt.toMillis() < 60000)) { + return; + } try { - const familyDoc = await db.collection("Households").doc(familyId).get(); - if (!familyDoc.exists) return; - const familySettings = familyDoc.data()?.settings || {}; - const reminderTime = familySettings.defaultReminderTime || 5; + const householdSnapshot = await admin.firestore() + .collection("Households") + .where("familyId", "==", event.familyId) + .limit(1) + .get(); + + if (householdSnapshot.empty) { + return; + } + + const householdDoc = householdSnapshot.docs[0]; + const householdSettings = householdDoc.data()?.settings || {}; + const reminderTime = householdSettings.defaultReminderTime || 15; const eventTime = event.startDate.toDate(); - const reminderThreshold = new Date(now.toDate().getTime() + reminderTime * 60 * 1000); - if (allDay) { - const eveningBefore = new Date(eventTime); - eveningBefore.setDate(eveningBefore.getDate() - 1); - eveningBefore.setHours(20, 0, 0, 0); - if (now.toDate() >= eveningBefore && !event.eveningReminderSent) { - const pushTokens = await getPushTokensForFamily(familyId); - if (pushTokens.length) { - await sendNotifications(pushTokens, { - title: "Tomorrow's All-Day Event", - body: `Tomorrow: ${title}`, - data: { type: "event_reminder", eventId: doc.id }, - }); - await doc.ref.update({ eveningReminderSent: true }); - } + const currentTime = now.toDate(); + const minutesUntilEvent = Math.round((eventTime - currentTime) / (60 * 1000)); + + // Only send exactly at the reminder time + if (minutesUntilEvent === reminderTime) { + // Double-check reminder hasn't been sent + const freshEventDoc = await doc.ref.get(); + if (freshEventDoc.data()?.reminderSent === true) { + return; } - } else if (eventTime <= reminderThreshold && !event.reminderSent) { - const pushTokens = await getPushTokensForFamily(familyId); - if (pushTokens.length) { + + // Mark as sent FIRST + await doc.ref.update({ + reminderSent: true, + reminderSentAt: admin.firestore.FieldValue.serverTimestamp() + }); + + const pushTokens = await getPushTokensForFamily(event.familyId); + if (pushTokens.length > 0) { + // Send notification await sendNotifications(pushTokens, { - title: "Upcoming Event", - body: `In ${reminderTime} minutes: ${title}`, - data: { type: "event_reminder", eventId: doc.id }, + title: "Upcoming Event Reminder", + body: `In ${reminderTime} minutes: ${event.title}`, + data: { + type: "event_reminder", + eventId: eventId, + familyId: event.familyId + } + }); + + // Store notification record + await storeNotification({ + type: "event_reminder", + familyId: event.familyId, + content: `Reminder: ${event.title} starts in ${reminderTime} minutes`, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + eventId: eventId + }); + + logger.info(`Sent reminder for event: ${event.title}`, { + eventId, + minutesUntilEvent, + reminderTime, + currentTime: currentTime.toISOString(), + eventTime: eventTime.toISOString() }); - await doc.ref.update({ reminderSent: true }); } } } catch (error) { - logger.error(`Error processing reminder for event ${doc.id}`, error); + logger.error(`Error processing reminder for event ${eventId}:`, error); } - })); + }); + + await Promise.all(processPromises); + }); +// 3. Add a function to reset reminder flags for testing +exports.resetReminderFlags = functions.https.onRequest(async (req, res) => { + try { + const batch = admin.firestore().batch(); + const events = await admin.firestore() + .collection("Events") + .where("reminderSent", "==", true) + .get(); + + events.docs.forEach(doc => { + batch.update(doc.ref, { + reminderSent: false, + reminderSentAt: null + }); + }); + + await batch.commit(); + res.status(200).send(`Reset ${events.size} events`); + } catch (error) { + res.status(500).send(error.message); + } +}); + +exports.initializeEventFlags = functions.firestore + .document('Events/{eventId}') + .onCreate(async (snapshot, context) => { + try { + const eventData = snapshot.data(); + if (eventData.reminderSent === undefined) { + await snapshot.ref.update({ + reminderSent: false, + reminderSentAt: null + }); + } + } catch (error) { + logger.error(`Error initializing event ${context.params.eventId}:`, error); + } }); /* ───────────────────────────────── @@ -465,11 +702,13 @@ exports.syncNewEventToGoogle = functions.firestore const newEvent = snapshot.data(); const eventId = context.params.eventId; - await snapshot.ref.update({ - reminderSent: false, - eveningReminderSent: false, - notifiedAt: null - }); + if (newEvent.reminderSent === undefined) { + await snapshot.ref.update({ + reminderSent: false, + eveningReminderSent: false, + notifiedAt: null + }); + } if (newEvent.externalOrigin === "google") { logger.info("[GOOGLE_SYNC] Skipping sync for Google-originated event", eventId); @@ -1138,93 +1377,6 @@ exports.notifyOnEventUpdate = functions.firestore } }); -/* ───────────────────────────────── - REMINDER FUNCTION (for upcoming events) -───────────────────────────────── */ -// We keep the reminder scheduler mostly as-is but ensure that once a notification is sent, the event is updated -exports.checkUpcomingEvents = functions.pubsub - .schedule("every 5 minutes") - .onRun(async (context) => { - const now = Timestamp.now(); - const thirtyMinutesFromNow = new Date(now.toDate().getTime() + 30 * 60 * 1000); - - logger.info(`Running check at ${now.toDate().toISOString()}`); - - const eventsSnapshot = await db.collection("Events") - .where("startDate", ">=", now) - .where("startDate", "<=", Timestamp.fromDate(thirtyMinutesFromNow)) - .get(); - - const batch = db.batch(); - const notificationPromises = []; - - for (const doc of eventsSnapshot.docs) { - const event = doc.data(); - if (!event?.startDate) continue; - - const { familyId, title, allDay } = event; - - // Skip if reminder already sent - if (event.reminderSent) { - logger.info(`Reminder already sent for event: ${title}`); - continue; - } - - try { - const familyDoc = await db.collection("Households").doc(familyId).get(); - if (!familyDoc.exists) continue; - - const familySettings = familyDoc.data()?.settings || {}; - const reminderTime = familySettings.defaultReminderTime || 10; - - const eventTime = event.startDate.toDate(); - const timeUntilEvent = eventTime.getTime() - now.toDate().getTime(); - const minutesUntilEvent = Math.floor(timeUntilEvent / (60 * 1000)); - - logger.info(`Checking event: "${title}"`, { - minutesUntilEvent, - reminderTime, - eventTime: eventTime.toISOString() - }); - - // Modified timing logic: Send reminder when we're close to the reminder time - // This ensures we don't miss the window between function executions - if (minutesUntilEvent <= reminderTime && minutesUntilEvent > 0) { - logger.info(`Preparing to send reminder for: ${title}`); - const pushTokens = await getPushTokensForFamily(familyId); - - if (pushTokens.length) { - await sendNotifications(pushTokens, { - title: "Upcoming Event", - body: `In ${minutesUntilEvent} minutes: ${title}`, - data: { type: 'event_reminder', eventId: doc.id } - }); - - batch.update(doc.ref, { - reminderSent: true, - lastReminderSent: Timestamp.now() - }); - - logger.info(`Reminder sent for: ${title}`); - } - } else { - logger.info(`Not yet time for reminder: ${title}`, { - minutesUntilEvent, - reminderTime - }); - } - } catch (error) { - logger.error(`Error processing reminder for event ${doc.id}:`, error); - } - } - - // Commit batch if there are any operations - if (batch._ops.length > 0) { - await batch.commit(); - logger.info(`Committed ${batch._ops.length} updates`); - } - }); - /* ───────────────────────────────── MIGRATION UTILITY ───────────────────────────────── */ @@ -1352,7 +1504,7 @@ exports.sendSyncNotification = onRequest(async (req, res) => { createdBy: userId, lastSyncTimestamp: admin.firestore.FieldValue.serverTimestamp(), settings: { - defaultReminderTime: 15, // Default 15 minutes reminder + defaultReminderTime: 30, // Default 30 minutes reminder } }); logger.info(`[SYNC] Created new household for family ${familyId}`); @@ -1699,4 +1851,88 @@ exports.forceWatchRenewal = onRequest(async (req, res) => { }); await batch.commit(); res.status(200).send('Forced renewal of all watches'); +}); + +exports.deleteFamily = functions.https.onCall(async (data, context) => { + if (!context.auth) { + throw new functions.https.HttpsError('unauthenticated', 'User must be authenticated'); + } + + const { familyId } = data; + + if (!familyId) { + throw new functions.https.HttpsError('invalid-argument', 'Family ID is required'); + } + + try { + const db = admin.firestore(); + + const requestingUserProfile = await db.collection('Profiles') + .doc(context.auth.uid) + .get(); + + if (!requestingUserProfile.exists || requestingUserProfile.data().userType !== 'parent') { + throw new functions.https.HttpsError('permission-denied', 'Only parents can delete families'); + } + + if (requestingUserProfile.data().familyId !== familyId) { + throw new functions.https.HttpsError('permission-denied', 'You can not delete other families'); + } + + const profilesSnapshot = await db.collection('Profiles') + .where('familyId', '==', familyId) + .get(); + + const batch = db.batch(); + const profileIds = []; + + for (const profile of profilesSnapshot.docs) { + const userId = profile.id; + profileIds.push(userId); + + const collections = [ + 'BrainDumps', + 'Groceries', + 'Todos', + 'Events', + //'Feedbacks' + ]; + + for (const collectionName of collections) { + const userDocsSnapshot = await db.collection(collectionName) + .where('creatorId', '==', userId) + .get(); + + userDocsSnapshot.docs.forEach(doc => { + batch.delete(doc.ref); + }); + } + + batch.delete(profile.ref); + } + + const householdSnapshot = await db.collection('Households') + .where('familyId', '==', familyId) + .get(); + + if (!householdSnapshot.empty) { + const householdDoc = householdSnapshot.docs[0]; + batch.delete(householdDoc.ref); + } else { + console.log('Household not found for familyId:', familyId); + } + + await batch.commit(); + + // Delete Firebase Auth accounts + await Promise.all(profileIds.map(userId => + admin.auth().deleteUser(userId) + )); + + return { success: true, message: 'Family deleted successfully' }; + + } catch (error) { + console.error('Error deleting family:', error); + throw new functions.https.HttpsError('internal', 'Error deleting family data'); + } }); \ No newline at end of file