Month calendar

This commit is contained in:
Milan Paunovic
2025-01-16 00:22:09 +01:00
parent adafaf1dfe
commit 60953c34bc

View File

@ -1,326 +1,363 @@
import React, {useCallback, useEffect, useMemo, useState} from "react"; import React, {useCallback, useMemo, useRef} from 'react';
import {Calendar} from "react-native-big-calendar"; import {
import {ActivityIndicator, StyleSheet, TouchableOpacity, View, ViewStyle} from "react-native"; 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 {useGetEvents} from "@/hooks/firebase/useGetEvents";
import {useAtom, useSetAtom} from "jotai"; import {useAtom, useSetAtom} from "jotai";
import { import {modeAtom, selectedDateAtom, selectedNewEventDateAtom} from "@/components/pages/calendar/atoms";
editVisibleAtom,
eventForEditAtom,
isAllDayAtom,
isFamilyViewAtom,
modeAtom,
selectedDateAtom,
selectedNewEventDateAtom,
selectedUserAtom,
} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext"; import {useAuthContext} from "@/contexts/AuthContext";
import {CalendarEvent} from "@/components/pages/calendar/interfaces"; import * as Device from "expo-device";
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"
interface EventCalendarProps { interface CalendarEvent {
calendarHeight: number; id: string;
// WAS USED FOR SCROLLABLE CALENDARS, PERFORMANCE WAS NOT OPTIMAL title: string;
calendarWidth: number; start: Date;
end: Date;
color?: string;
} }
const getTotalMinutes = () => { interface CustomMonthCalendarProps {
const date = new Date(); weekStartsOn?: 0 | 1;
return Math.abs(date.getUTCHours() * 60 + date.getUTCMinutes() - 200); }
};
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<EventCalendarProps> = React.memo( export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
({calendarHeight}) => { const {data: rawEvents, isLoading} = useGetEvents();
const {data: events, isLoading} = useGetEvents(); const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const {profileData, user} = useAuthContext(); const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom); const [mode, setMode] = useAtom(modeAtom);
const [mode, setMode] = useAtom(modeAtom); const {profileData} = useAuthContext();
const [isFamilyView] = useAtom(isFamilyViewAtom);
//tablet view filter const scrollViewRef = useRef<ScrollView>(null);
const [selectedUser] = useAtom(selectedUserAtom); 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 weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const [isAllDay, setIsAllDay] = useAtom(isAllDayAtom);
const setEventForEdit = useSetAtom(eventForEditAtom);
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const {isSyncing} = useSyncEvents() const events = useMemo(() => {
const [offsetMinutes, setOffsetMinutes] = useState(getTotalMinutes()); if (!rawEvents?.length) return [];
useCalSync() if (!selectedDate) return [];
const todaysDate = new Date(); const rangeStart = subDays(selectedDate, CALENDAR_BUFFER_DAYS);
const rangeEnd = addDays(selectedDate, CALENDAR_BUFFER_DAYS);
const handlePressEvent = useCallback( return rawEvents.filter((event) => {
(event: CalendarEvent) => { if (!event?.start || !event?.end) {
if (mode === "day" || mode === "week" || mode === "3days") { return false;
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
);
} }
const filteredEvents = const startDate = event.start instanceof Date ? event.start : new Date(event.start);
eventsToFilter?.filter( const endDate = event.end instanceof Date ? event.end : new Date(event.end);
(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),
})
) ?? [];
return {filteredEvents}; if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
}, [events, selectedDate, mode, selectedUser]); return false;
}
useEffect(() => { return startDate <= rangeEnd && endDate >= rangeStart;
setOffsetMinutes(getTotalMinutes()); });
}, [events, mode]); }, [rawEvents, selectedDate]);
if (isLoading || !events) { const onDayPress = useCallback(
return ( (date: Date) => {
<View style={styles.loadingContainer}> date && setSelectedDate(date);
{isSyncing && <Text>Syncing...</Text>} setTimeout(() => {
<ActivityIndicator size="large" color="#0000ff"/> setMode("day");
</View> }, 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 ( // Add padding days at the end
<> const lastDay = days[days.length - 1];
{isSyncing && ( const endPadding = [];
<View style={styles.loadingContainer}> let endDay = lastDay.getDay();
{isSyncing && <Text>Syncing...</Text>} while (endDay !== (weekStartsOn + 6) % 7) {
<ActivityIndicator size="large" color="#0000ff"/> endDay = (endDay + 1) % 7;
</View> endPadding.push(addDays(lastDay, endPadding.length + 1));
)} }
<Calendar
bodyContainerStyle={styles.calHeader}
swipeEnabled
mode={"month"}
sortedMonthView
events={filteredEvents}
// renderEvent={renderEvent}
eventCellStyle={memoizedEventCellStyle}
allDayEventCellStyle={memoizedEventCellStyle}
// enableEnrichedEvents={true}
// enrichedEventsByDate={enrichedEvents}
onPressEvent={handlePressEvent}
weekStartsOn={memoizedWeekStartsOn}
height={calendarHeight}
activeDate={todaysDate}
date={selectedDate}
onPressCell={handlePressCell}
headerContentStyle={memoizedHeaderContentStyle}
onSwipeEnd={handleSwipeEnd}
scrollOffsetMinutes={offsetMinutes}
theme={{
palette: {
nowIndicator: profileData?.eventColor || "#fd1575",
gray: {
"100": "#e8eaed",
"200": "#e8eaed",
"500": "#b7b7b7",
"800": "#919191",
},
},
typography: {
fontFamily: "PlusJakartaSans_500Medium",
sm: {fontFamily: "Manrope_600SemiBold", fontSize: 8},
xl: {
fontFamily: "PlusJakartaSans_500Medium",
fontSize: 14,
},
moreLabel: {},
xs: {fontSize: 10},
},
}}
dayHeaderStyle={dateStyle}
dayHeaderHighlightColor={"white"}
showAdjacentMonths
headerContainerStyle={mode !== "month" ? {
overflow: "hidden",
} : {}}
hourStyle={styles.hourStyle}
onPressDateHeader={handlePressDayHeader}
ampm
// renderCustomDateForMonth={renderCustomDateForMonth}
/>
{Device.deviceType === DeviceType.TABLET && <View style={{backgroundColor: 'white', height: '9%', width: '100%'}}/>}
</>
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<NativeScrollEvent>) => {
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 (
<TouchableOpacity
key={event.id}
style={[styles.event, {backgroundColor: event.color || '#6200ee'}]}
onPress={() => onDayPress(event.start)}
>
<Text style={styles.eventText} numberOfLines={1}>
{event.title}
</Text>
</TouchableOpacity>
); );
} }, [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 (
<TouchableOpacity
key={index}
style={[styles.day, {width: dayWidth}]}
onPress={() => onDayPress(date)}
>
<View style={[
styles.dateContainer,
isToday && styles.todayContainer,
]}>
<Text style={[
styles.dateText,
!isCurrentMonth && styles.outsideMonthText,
isToday && styles.todayText,
]}>
{format(date, 'd')}
</Text>
</View>
<View style={styles.eventsContainer}>
{dayEvents.slice(0, MAX_VISIBLE_EVENTS).map(renderEvent)}
{hasMoreEvents && (
<Text style={styles.moreEvents}>
{dayEvents.length - MAX_VISIBLE_EVENTS} More
</Text>
)}
</View>
</TouchableOpacity>
);
}, [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 (
<View style={styles.container}>
<View style={styles.header}>
{sortedDaysOfWeek.map((day, index) => (
<View key={index} style={[styles.weekDay, {width: `${100 / 7}%`}]}>
<Text style={styles.weekDayText}>{day}</Text>
</View>
))}
</View>
<ScrollView
ref={scrollViewRef}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
onScroll={handleScroll}
onMomentumScrollEnd={onMomentumScrollEnd}
scrollEventThrottle={16}
contentOffset={{x: screenWidth, y: 0}}
style={{flex: 1}}
>
{monthsToRender.map(({date, days}, monthIndex) => (
<View
key={monthIndex}
style={[styles.scrollView, {width: screenWidth}]}
>
<View style={styles.daysGrid}>
{days.map((day, index) => renderDay(day, index))}
</View>
</View>
))}
</ScrollView>
</View>
);
};
const HEADER_HEIGHT = 40;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
segmentslblStyle: { container: {
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: {
flex: 1, 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%', height: '100%',
},
header: {
flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
}, },
eventTitle: { scrollView: {
color: 'white', 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, 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',
},
});