import React, {useCallback, useEffect, useMemo, useState} from "react"; import {Calendar} from "react-native-big-calendar"; import {ActivityIndicator, StyleSheet, TouchableOpacity, View, ViewStyle} from "react-native"; import {useGetEvents} from "@/hooks/firebase/useGetEvents"; import {useAtom, useSetAtom} from "jotai"; import { editVisibleAtom, eventForEditAtom, isAllDayAtom, isFamilyViewAtom, modeAtom, selectedDateAtom, selectedNewEventDateAtom, selectedUserAtom, } from "@/components/pages/calendar/atoms"; import {useAuthContext} from "@/contexts/AuthContext"; import {CalendarEvent} from "@/components/pages/calendar/interfaces"; import {Text} from "react-native-ui-lib"; import {addDays, compareAsc, format, isWithinInterval, subDays} from "date-fns"; import {useCalSync} from "@/hooks/useCalSync"; import {useSyncEvents} from "@/hooks/useSyncOnScroll"; import {colorMap} from "@/constants/colorMap"; import {useGetFamilyMembers} from "@/hooks/firebase/useGetFamilyMembers"; import CachedImage from "expo-cached-image"; import { DeviceType } from "expo-device"; import * as Device from "expo-device" interface EventCalendarProps { calendarHeight: number; // WAS USED FOR SCROLLABLE CALENDARS, PERFORMANCE WAS NOT OPTIMAL calendarWidth: number; } const getTotalMinutes = () => { const date = new Date(); return Math.abs(date.getUTCHours() * 60 + date.getUTCMinutes() - 200); }; const processEventsForSideBySide = (events: CalendarEvent[]) => { if (!events) return []; // Group events by day and time slot const timeSlots: { [key: string]: CalendarEvent[] } = {}; events.forEach(event => { const startDate = new Date(event.start); const endDate = new Date(event.end); // If it's an all-day event, mark it and add it directly if (event.allDay) { const key = `${startDate.toISOString().split('T')[0]}-allday`; if (!timeSlots[key]) timeSlots[key] = []; timeSlots[key].push({ ...event, isAllDayEvent: true, width: 1, xPos: 0 }); return; } // Handle multi-day events if (startDate.toDateString() !== endDate.toDateString()) { // Create array of dates between start and end const dates = []; let currentDate = new Date(startDate); while (currentDate <= endDate) { dates.push(new Date(currentDate)); currentDate.setDate(currentDate.getDate() + 1); } // Create segments for each day dates.forEach((date, index) => { const isFirstDay = index === 0; const isLastDay = index === dates.length - 1; let segmentStart, segmentEnd; if (isFirstDay) { // First day: use original start time to end of day segmentStart = new Date(startDate); segmentEnd = new Date(date); segmentEnd.setHours(23, 59, 59); } else if (isLastDay) { // Last day: use start of day to original end time segmentStart = new Date(date); segmentStart.setHours(0, 0, 0); segmentEnd = new Date(endDate); } else { // Middle days: full day segmentStart = new Date(date); segmentStart.setHours(0, 0, 0); segmentEnd = new Date(date); segmentEnd.setHours(23, 59, 59); } const key = `${segmentStart.toISOString().split('T')[0]}-${segmentStart.getHours()}`; if (!timeSlots[key]) timeSlots[key] = []; timeSlots[key].push({ ...event, start: segmentStart, end: segmentEnd, isMultiDaySegment: true, isFirstDay, isLastDay, originalStart: startDate, originalEnd: endDate, allDay: true // Mark multi-day events as all-day events }); }); } else { // Regular event const key = `${startDate.toISOString().split('T')[0]}-${startDate.getHours()}`; if (!timeSlots[key]) timeSlots[key] = []; timeSlots[key].push(event); } }); // Process all time slots return Object.values(timeSlots).flatMap(slotEvents => { // Sort events by start time slotEvents.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); // Find overlapping events (only for non-all-day events) return slotEvents.map((event, index) => { // If it's an all-day or multi-day event, return as is if (event.allDay || event.isMultiDaySegment) { return { ...event, width: 1, xPos: 0 }; } // Handle regular events const overlappingEvents = slotEvents.filter((otherEvent, otherIndex) => { if (index === otherIndex || otherEvent.allDay || otherEvent.isMultiDaySegment) return false; const eventStart = new Date(event.start); const eventEnd = new Date(event.end); const otherStart = new Date(otherEvent.start); const otherEnd = new Date(otherEvent.end); return (eventStart < otherEnd && eventEnd > otherStart); }); const total = overlappingEvents.length + 1; const position = index % total; return { ...event, width: 1 / total, xPos: position / total }; }); }); }; const renderEvent = (event: CalendarEvent & { width: number; xPos: number; isMultiDaySegment?: boolean; isFirstDay?: boolean; isLastDay?: boolean; originalStart?: Date; originalEnd?: Date; isAllDayEvent?: boolean; allDay?: boolean; eventColor?: string; attendees?: string[]; creatorId?: string; pfp?: string; firstName?: string; lastName?: string; notes?: string; hideHours?: boolean; }, props: any) => { const {data: familyMembers} = useGetFamilyMembers(); const attendees = useMemo(() => { if (!familyMembers || !event.attendees) return event?.creatorId ? [event?.creatorId] : []; return familyMembers.filter(member => event?.attendees?.includes(member?.uid!)); }, [familyMembers, event.attendees]); if (event.allDay && !!event.isMultiDaySegment) { return ( {event.title} {event.isMultiDaySegment && ` (${format(new Date(event.start), 'MMM d')} - ${format(new Date(event.end), 'MMM d')})` } ); } const originalStyle = Array.isArray(props.style) ? props.style[0] : props.style; console.log('Rendering event:', { title: event.title, start: event.start, end: event.end, width: event.width, xPos: event.xPos, isMultiDaySegment: event.isMultiDaySegment }); // Ensure we have Date objects const startDate = event.start instanceof Date ? event.start : new Date(event.start); const endDate = event.end instanceof Date ? event.end : new Date(event.end); const hourHeight = props.hourHeight || 60; const startHour = startDate.getHours(); const startMinutes = startDate.getMinutes(); const topPosition = (startHour + startMinutes / 60) * hourHeight; const endHour = endDate.getHours(); const endMinutes = endDate.getMinutes(); const duration = (endHour + endMinutes / 60) - (startHour + startMinutes / 60); const height = duration * hourHeight; const formatTime = (date: Date) => { const hours = date.getHours(); const minutes = date.getMinutes(); const ampm = hours >= 12 ? 'pm' : 'am'; const formattedHours = hours % 12 || 12; const formattedMinutes = minutes.toString().padStart(2, '0'); return `${formattedHours}:${formattedMinutes}${ampm}`; }; const timeString = event.isMultiDaySegment ? event.isFirstDay ? `${formatTime(startDate)} → 12:00PM` : event.isLastDay ? `12:00am → ${formatTime(endDate)}` : 'All day' : `${formatTime(startDate)} - ${formatTime(endDate)}`; return ( {event.title} {timeString} {/* Attendees Section */} {attendees?.length > 0 && ( {attendees?.filter(x=>x?.firstName).slice(0, 5).map((attendee, index) => ( {attendee.pfp ? ( ) : ( {attendee?.firstName?.at(0)} {attendee?.lastName?.at(0)} )} ))} {attendees.length > 3 && ( +{attendees.length - 3} )} )} ); }; export const EventCalendar: React.FC = React.memo( ({calendarHeight}) => { const {data: events, isLoading} = useGetEvents(); const {profileData, user} = useAuthContext(); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); const [mode, setMode] = useAtom(modeAtom); const [isFamilyView] = useAtom(isFamilyViewAtom); //tablet view filter const [selectedUser] = useAtom(selectedUserAtom); const setEditVisible = useSetAtom(editVisibleAtom); const [isAllDay, setIsAllDay] = useAtom(isAllDayAtom); const setEventForEdit = useSetAtom(eventForEditAtom); const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); const {isSyncing} = useSyncEvents() const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes()); useCalSync() const todaysDate = new Date(); const handlePressEvent = useCallback( (event: CalendarEvent) => { if (mode === "day" || mode === "week" || mode === "3days") { setEditVisible(true); setEventForEdit(event); } else { setMode("day"); setSelectedDate(event.start); } }, [setEditVisible, setEventForEdit, mode] ); const handlePressCell = useCallback( (date: Date) => { if (mode === "day" || mode === "week" || mode === "3days") { setSelectedNewEndDate(date); } else { setMode("day"); setSelectedDate(date); } }, [mode, setSelectedNewEndDate, setSelectedDate] ); const handlePressDayHeader = useCallback( (date: Date) => { if (mode === "day") { setIsAllDay(true); setSelectedNewEndDate(date); setEditVisible(true); } if (mode === 'week' || mode === '3days') { setSelectedDate(date) setMode("day") } }, [mode, setSelectedNewEndDate] ); const handleSwipeEnd = useCallback( (date: Date) => { setSelectedDate(date); }, [setSelectedDate] ); const memoizedEventCellStyle = useCallback( (event: CalendarEvent) => { let eventColor = event.eventColor; if (!isFamilyView && (event.attendees?.includes(user?.uid!) || event.creatorId! === user?.uid)) { eventColor = profileData?.eventColor ?? colorMap.teal; } return {backgroundColor: eventColor, fontSize: 14} }, [] ); const memoizedWeekStartsOn = useMemo( () => (profileData?.firstDayOfWeek === "Mondays" ? 1 : 0), [profileData] ); const isSameDate = useCallback((date1: Date, date2: Date) => { return ( date1.getDate() === date2.getDate() && date1.getMonth() === date2.getMonth() && date1.getFullYear() === date2.getFullYear() ); }, []); const dayHeaderColor = useMemo(() => { return isSameDate(todaysDate, selectedDate) ? "white" : "#4d4d4d"; }, [selectedDate, mode]); const dateStyle = useMemo(() => { if (mode === "week" || mode === "3days") return undefined; return isSameDate(todaysDate, selectedDate) && mode === "day" ? styles.dayHeader : styles.otherDayHeader; }, [selectedDate, mode]); const memoizedHeaderContentStyle = useMemo(() => { if (mode === "day") { return styles.dayModeHeader; } else if (mode === "week" || mode === "3days") { return styles.weekModeHeader; } else if (mode === "month") { return styles.monthModeHeader; } else { return {}; } }, [mode]); const {enrichedEvents, filteredEvents} = useMemo(() => { const startTime = Date.now(); const startOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1; const endOffset = mode === "month" ? 40 : (mode === "week" || mode === "3days") ? 10 : 1; let eventsToFilter = events; if (selectedUser && Device.deviceType === DeviceType.TABLET) { eventsToFilter = events?.filter(event => event.attendees?.includes(selectedUser.uid) || event.creatorId === selectedUser.uid ); } const filteredEvents = eventsToFilter?.filter( (event) => event.start && event.end && isWithinInterval(event.start, { start: subDays(selectedDate, startOffset), end: addDays(selectedDate, endOffset), }) && isWithinInterval(event.end, { start: subDays(selectedDate, startOffset), end: addDays(selectedDate, endOffset), }) ) ?? []; const enrichedEvents = filteredEvents.reduce((acc, event) => { const dateKey = event.start.toISOString().split("T")[0]; acc[dateKey] = acc[dateKey] || []; acc[dateKey].push({ ...event, overlapPosition: false, overlapCount: 0, }); acc[dateKey].sort((a: { start: any; }, b: { start: any; }) => compareAsc(a.start, b.start)); return acc; }, {} as Record); const endTime = Date.now(); // console.log("memoizedEvents computation time:", endTime - startTime, "ms"); return {enrichedEvents, filteredEvents}; }, [events, selectedDate, mode, selectedUser]); const renderCustomDateForMonth = (date: Date) => { const circleStyle = useMemo( () => ({ width: 30, height: 30, justifyContent: "center", alignItems: "center", borderRadius: 15, }), [] ); const defaultStyle = useMemo( () => ({ ...circleStyle, }), [circleStyle] ); const currentDateStyle = useMemo( () => ({ ...circleStyle, backgroundColor: "#4184f2", }), [circleStyle] ); const renderDate = useCallback( (date: Date) => { const isCurrentDate = isSameDate(todaysDate, date); const appliedStyle = isCurrentDate ? currentDateStyle : defaultStyle; return ( {date.getDate()} ); }, [todaysDate, currentDateStyle, defaultStyle] ); return renderDate(date); }; const processedEvents = useMemo(() => { return processEventsForSideBySide(filteredEvents); }, [filteredEvents]); useEffect(() => { setOffsetMinutes(getTotalMinutes()); }, [events, mode]); if (isLoading) { return ( {isSyncing && Syncing...} ); } return ( <> {isSyncing && ( {isSyncing && Syncing...} )} ); } ); const styles = StyleSheet.create({ segmentslblStyle: { fontSize: 12, fontFamily: "Manrope_600SemiBold", }, calHeader: { borderWidth: 0, paddingBottom: 60, }, dayModeHeader: { alignSelf: "flex-start", justifyContent: "space-between", alignContent: "center", width: 38, right: 42, height: 13, }, weekModeHeader: {}, monthModeHeader: {}, loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center", position: "absolute", width: "100%", height: "100%", zIndex: 100, backgroundColor: "rgba(255, 255, 255, 0.9)", }, dayHeader: { backgroundColor: "#4184f2", aspectRatio: 1, borderRadius: 100, alignItems: "center", justifyContent: "center", }, otherDayHeader: { backgroundColor: "transparent", color: "#919191", aspectRatio: 1, borderRadius: 100, alignItems: "center", justifyContent: "center", }, hourStyle: { color: "#5f6368", fontSize: 12, fontFamily: "Manrope_500Medium", }, eventCell: { flex: 1, borderRadius: 4, padding: 4, height: '100%', justifyContent: 'center', }, eventTitle: { color: 'white', fontSize: 12, fontFamily: "PlusJakartaSans_500Medium", }, });