From 60953c34bc99dd542aa5494057ee90cda89a559e Mon Sep 17 00:00:00 2001 From: Milan Paunovic Date: Thu, 16 Jan 2025 00:22:09 +0100 Subject: [PATCH] Month calendar --- components/pages/calendar/MonthCalendar.tsx | 629 +++++++++++--------- 1 file changed, 333 insertions(+), 296 deletions(-) diff --git a/components/pages/calendar/MonthCalendar.tsx b/components/pages/calendar/MonthCalendar.tsx index 6bbb21d..af86966 100644 --- a/components/pages/calendar/MonthCalendar.tsx +++ b/components/pages/calendar/MonthCalendar.tsx @@ -1,326 +1,363 @@ -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 React, {useCallback, useMemo, useRef} from 'react'; +import { + Dimensions, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, + NativeScrollEvent, + NativeSyntheticEvent, +} from 'react-native'; +import { + addDays, + eachDayOfInterval, + endOfMonth, + format, + isSameDay, + isSameMonth, + isWithinInterval, + startOfMonth, + subDays, + addMonths, + subMonths +} from 'date-fns'; 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 {modeAtom, selectedDateAtom, selectedNewEventDateAtom} 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, getEventTextColor} 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" +import * as Device from "expo-device"; -interface EventCalendarProps { - calendarHeight: number; - // WAS USED FOR SCROLLABLE CALENDARS, PERFORMANCE WAS NOT OPTIMAL - calendarWidth: number; +interface CalendarEvent { + id: string; + title: string; + start: Date; + end: Date; + color?: string; } -const getTotalMinutes = () => { - const date = new Date(); - return Math.abs(date.getUTCHours() * 60 + date.getUTCMinutes() - 200); -}; +interface CustomMonthCalendarProps { + weekStartsOn?: 0 | 1; +} +const DAYS_OF_WEEK = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MAX_VISIBLE_EVENTS = 3; +const CALENDAR_BUFFER_DAYS = 40; -export const MonthCalendar: 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); +export const MonthCalendar: React.FC = () => { + const {data: rawEvents, isLoading} = useGetEvents(); + const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); + const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); + const [mode, setMode] = useAtom(modeAtom); + const {profileData} = useAuthContext(); - //tablet view filter - const [selectedUser] = useAtom(selectedUserAtom); + const scrollViewRef = useRef(null); + const isTablet = Device.deviceType === Device.DeviceType.TABLET; + const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width; + const dayWidth = (screenWidth - 32) / 7; + const isScrolling = useRef(false); + const currentScrollX = useRef(screenWidth); - const setEditVisible = useSetAtom(editVisibleAtom); - const [isAllDay, setIsAllDay] = useAtom(isAllDayAtom); - const setEventForEdit = useSetAtom(eventForEditAtom); - const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom); + const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1; - const {isSyncing} = useSyncEvents() - const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes()); - useCalSync() + const events = useMemo(() => { + if (!rawEvents?.length) return []; + if (!selectedDate) return []; - const todaysDate = new Date(); + const rangeStart = subDays(selectedDate, CALENDAR_BUFFER_DAYS); + const rangeEnd = addDays(selectedDate, CALENDAR_BUFFER_DAYS); - 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) => { - date && setSelectedDate(date); - setTimeout(() => { - setMode("day"); - }, 100) - }, - [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, color: getEventTextColor(event?.eventColor)} - }, - [] - ); - - 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 {filteredEvents} = useMemo(() => { - 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 - ); + return rawEvents.filter((event) => { + if (!event?.start || !event?.end) { + return false; } - const filteredEvents = - eventsToFilter?.filter( - (event) => - event?.start instanceof Date && - event?.end instanceof Date && - isWithinInterval(event.start, { - start: subDays(selectedDate, startOffset), - end: addDays(selectedDate, endOffset), - }) && - isWithinInterval(event.end, { - start: subDays(selectedDate, startOffset), - end: addDays(selectedDate, endOffset), - }) - ) ?? []; + const startDate = event.start instanceof Date ? event.start : new Date(event.start); + const endDate = event.end instanceof Date ? event.end : new Date(event.end); - return {filteredEvents}; - }, [events, selectedDate, mode, selectedUser]); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + return false; + } - useEffect(() => { - setOffsetMinutes(getTotalMinutes()); - }, [events, mode]); + return startDate <= rangeEnd && endDate >= rangeStart; + }); + }, [rawEvents, selectedDate]); - if (isLoading || !events) { - return ( - - {isSyncing && Syncing...} - - - ); + const onDayPress = useCallback( + (date: Date) => { + date && setSelectedDate(date); + setTimeout(() => { + setMode("day"); + }, 100) + }, + [mode, setSelectedNewEndDate, setSelectedDate] + ); + + const getMonthData = useCallback((date: Date) => { + const start = startOfMonth(date); + const end = endOfMonth(date); + const days = eachDayOfInterval({start, end}); + + // Add padding days at the start + const firstDay = days[0]; + const startPadding = []; + let startDay = firstDay.getDay(); + while (startDay !== weekStartsOn) { + startDay = (startDay - 1 + 7) % 7; + startPadding.unshift(addDays(firstDay, -startPadding.length - 1)); } - return ( - <> - {isSyncing && ( - - {isSyncing && Syncing...} - - - )} - - {Device.deviceType === DeviceType.TABLET && } - + // Add padding days at the end + const lastDay = days[days.length - 1]; + const endPadding = []; + let endDay = lastDay.getDay(); + while (endDay !== (weekStartsOn + 6) % 7) { + endDay = (endDay + 1) % 7; + endPadding.push(addDays(lastDay, endPadding.length + 1)); + } + return [...startPadding, ...days, ...endPadding]; + }, [weekStartsOn]); + + const monthsToRender = useMemo(() => { + const prevMonth = subMonths(selectedDate, 1); + const nextMonth = addMonths(selectedDate, 1); + + return [ + { + date: prevMonth, + days: getMonthData(prevMonth) + }, + { + date: selectedDate, + days: getMonthData(selectedDate) + }, + { + date: nextMonth, + days: getMonthData(nextMonth) + } + ]; + }, [selectedDate, getMonthData]); + + const handleScroll = useCallback((event: NativeSyntheticEvent) => { + if (!isScrolling.current) { + currentScrollX.current = event.nativeEvent.contentOffset.x; + } + }, []); + + const onMomentumScrollEnd = useCallback(() => { + if (isScrolling.current) return; + + const pageWidth = screenWidth; + const currentX = currentScrollX.current; + + if (currentX < pageWidth / 2) { + isScrolling.current = true; + setSelectedDate(prev => { + const newDate = subMonths(prev, 1); + // Immediately scroll back to center + scrollViewRef.current?.scrollTo({ + x: pageWidth, + animated: false, + }); + isScrolling.current = false; + currentScrollX.current = pageWidth; + return newDate; + }); + } else if (currentX > pageWidth * 1.5) { + isScrolling.current = true; + setSelectedDate(prev => { + const newDate = addMonths(prev, 1); + // Immediately scroll back to center + scrollViewRef.current?.scrollTo({ + x: pageWidth, + animated: false, + }); + isScrolling.current = false; + currentScrollX.current = pageWidth; + return newDate; + }); + } + }, [screenWidth, setSelectedDate]); + + const getEventsForDay = useCallback((date: Date) => { + return events?.filter(event => + isSameDay(new Date(event.start), date) + ) ?? []; + }, [events]); + + const renderEvent = useCallback((event: CalendarEvent) => { + return ( + onDayPress(event.start)} + > + + {event.title} + + ); - } -); + }, [onDayPress]); + + const renderDay = useCallback((date: Date, index: number) => { + const dayEvents = getEventsForDay(date); + const isCurrentMonth = isSameMonth(date, selectedDate); + const isToday = isSameDay(date, new Date()); + const hasMoreEvents = dayEvents.length > MAX_VISIBLE_EVENTS; + + return ( + onDayPress(date)} + > + + + {format(date, 'd')} + + + + {dayEvents.slice(0, MAX_VISIBLE_EVENTS).map(renderEvent)} + {hasMoreEvents && ( + + {dayEvents.length - MAX_VISIBLE_EVENTS} More + + )} + + + ); + }, [dayWidth, selectedDate, getEventsForDay, onDayPress, renderEvent]); + + const sortedDaysOfWeek = useMemo(() => { + const days = [...DAYS_OF_WEEK]; + const sortedDays = days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn)); + return sortedDays; + }, [weekStartsOn]); + + return ( + + + {sortedDaysOfWeek.map((day, index) => ( + + {day} + + ))} + + + {monthsToRender.map(({date, days}, monthIndex) => ( + + + {days.map((day, index) => renderDay(day, index))} + + + ))} + + + ); +}; + +const HEADER_HEIGHT = 40; const styles = StyleSheet.create({ - segmentslblStyle: { - fontSize: 12, - fontFamily: "Manrope_600SemiBold", - }, - calHeader: { - borderWidth: 0, - paddingBottom: 0, - }, - dayModeHeader: { - alignSelf: "flex-start", - justifyContent: "space-between", - alignContent: "center", - width: 38, - right: 42, - height: 13, - }, - weekModeHeader: {}, - monthModeHeader: {}, - loadingContainer: { + container: { 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%', + }, + header: { + flexDirection: 'row', justifyContent: 'center', + paddingHorizontal: 16, + paddingVertical: 8, }, - eventTitle: { - color: 'white', + scrollView: { + flex: 1, + }, + monthContainer: { + flex: 1, + }, + daysGrid: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + paddingHorizontal: 16, + }, + day: { + height: '15%', // 100% / 6 weeks + padding: 4, + borderWidth: 0.5, + borderColor: '#eee', + }, + weekDay: { + alignItems: 'center', + justifyContent: 'center', + height: HEADER_HEIGHT, + }, + scrollContent: { + flex: 1, + }, + weekDayText: { fontSize: 12, - fontFamily: "PlusJakartaSans_500Medium", + fontWeight: '600', + color: '#666', }, -}); + dateContainer: { + width: 24, + height: 24, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 12, + }, + todayContainer: { + backgroundColor: '#6200ee', + }, + dateText: { + fontSize: 12, + fontWeight: '500', + color: '#333', + }, + todayText: { + color: '#fff', + }, + outsideMonthText: { + color: '#ccc', + }, + eventsContainer: { + flex: 1, + marginTop: 2, + }, + event: { + borderRadius: 4, + padding: 2, + marginVertical: 1, + }, + eventText: { + fontSize: 10, + color: '#fff', + }, + moreEvents: { + fontSize: 10, + color: '#666', + textAlign: 'center', + }, +}); \ No newline at end of file