mirror of
https://github.com/urosran/cally.git
synced 2025-11-27 00:44:54 +00:00
363 lines
11 KiB
TypeScript
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',
|
|
},
|
|
}); |