Files
cally/components/pages/calendar/MonthCalendar.tsx
2025-01-17 00:39:20 +01:00

363 lines
11 KiB
TypeScript

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 {modeAtom, selectedDateAtom, selectedNewEventDateAtom} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext";
import * as Device from "expo-device";
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
color?: string;
}
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<CustomMonthCalendarProps> = () => {
const {data: rawEvents, isLoading} = useGetEvents();
const [selectedDate, setSelectedDate] = useAtom(selectedDateAtom);
const setSelectedNewEndDate = useSetAtom(selectedNewEventDateAtom);
const [mode, setMode] = useAtom(modeAtom);
const {profileData} = useAuthContext();
const scrollViewRef = useRef<ScrollView>(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 weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const events = useMemo(() => {
if (!rawEvents?.length) return [];
if (!selectedDate) return [];
const rangeStart = subDays(selectedDate, CALENDAR_BUFFER_DAYS);
const rangeEnd = addDays(selectedDate, CALENDAR_BUFFER_DAYS);
return rawEvents.filter((event) => {
if (!event?.start || !event?.end) {
return false;
}
const startDate = event.start instanceof Date ? event.start : new Date(event.start);
const endDate = event.end instanceof Date ? event.end : new Date(event.end);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return false;
}
return startDate <= rangeEnd && endDate >= rangeStart;
});
}, [rawEvents, selectedDate]);
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));
}
// 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<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({
container: {
flex: 1,
height: '100%',
},
header: {
flexDirection: 'row',
justifyContent: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
},
scrollView: {
flex: 1,
},
monthContainer: {
flex: 1,
},
daysGrid: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center'
},
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,
fontWeight: '600',
color: '#666',
},
dateContainer: {
minWidth: 20,
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',
},
});