Files
cally/components/pages/calendar/MonthCalendar.tsx
2025-02-02 22:28:40 +01:00

572 lines
18 KiB
TypeScript

import React, {useCallback, useMemo, useRef} from 'react';
import {Dimensions, StyleSheet, Text, TouchableOpacity, View,} from 'react-native';
import {
addDays,
addMonths,
eachDayOfInterval,
endOfMonth,
format,
isSameDay,
isSameMonth,
isWithinInterval,
startOfMonth,
} from 'date-fns';
import {useGetEvents} from "@/hooks/firebase/useGetEvents";
import {useSetAtom} from "jotai";
import {modeAtom, selectedDateAtom} from "@/components/pages/calendar/atoms";
import {useAuthContext} from "@/contexts/AuthContext";
import {FlashList} from "@shopify/flash-list";
import * as Device from "expo-device";
import {CalendarController} from "@/components/pages/calendar/CalendarController";
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 CENTER_MONTH_INDEX = 12;
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
<TouchableOpacity
style={[styles.event, {backgroundColor: event?.eventColor || '#6200ee'}]}
onPress={onPress}
>
<Text style={styles.eventText} numberOfLines={1}>
{event.title}
</Text>
</TouchableOpacity>
));
interface CalendarEvent {
id: string;
title: string;
start: Date;
end: Date;
color?: string;
weekPosition?: number;
}
const MultiDayEvent = React.memo(({
event,
isStart,
isEnd,
onPress,
}: {
event: CalendarEvent;
dayWidth: number;
isStart: boolean;
isEnd: boolean;
onPress: () => void;
}) => {
const style = {
position: 'absolute' as const,
height: 14,
backgroundColor: event?.eventColor || '#6200ee',
padding: 2,
zIndex: 1,
left: isStart ? 4 : -0.5, // Extend slightly into the border
right: isEnd ? 4 : -0.5, // Extend slightly into the border
top: event.weekPosition ? event.weekPosition * 24 : 0,
borderRadius: 4,
borderTopLeftRadius: isStart ? 4 : 0,
borderBottomLeftRadius: isStart ? 4 : 0,
borderTopRightRadius: isEnd ? 4 : 0,
borderBottomRightRadius: isEnd ? 4 : 0,
justifyContent: 'center',
};
return (
<TouchableOpacity style={style} onPress={onPress}>
{isStart && (
<Text style={[styles.eventText]} numberOfLines={1}>
{event.title}
</Text>
)}
</TouchableOpacity>
);
});
const Day = React.memo((
{
date,
events,
multiDayEvents,
dayWidth,
onPress
}: {
date: Date;
events: CalendarEvent[];
multiDayEvents: CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
}) => {
const isCurrentMonth = isSameMonth(date, new Date());
const isToday = isSameDay(date, new Date());
const remainingSlots = Math.max(0, MAX_VISIBLE_EVENTS - multiDayEvents.length);
const singleDayEvents = events.filter(event => !event.isMultiDay);
const visibleSingleDayEvents = singleDayEvents.slice(0, remainingSlots);
const totalHiddenEvents = singleDayEvents.length - remainingSlots;
const maxMultiDayPosition = multiDayEvents.length > 0
? Math.max(...multiDayEvents.map(e => e.weekPosition || 0)) + 1
: 0;
const multiDayEventsHeight = maxMultiDayPosition * 16; // Height for multi-day events
return (
<View style={[styles.day, {width: dayWidth}]}>
<TouchableOpacity
style={styles.dayContent}
onPress={() => onPress(date)}
>
<View style={[
styles.dateContainer,
isToday && {backgroundColor: events?.[0]?.eventColor},
]}>
<Text style={[
styles.dateText,
!isCurrentMonth && styles.outsideMonthText,
isToday && styles.todayText,
]}>
{format(date, 'd')}
</Text>
</View>
{/* Multi-day events container */}
<View style={[styles.multiDayContainer, {height: multiDayEventsHeight}]}>
{multiDayEvents.map(event => (
<MultiDayEvent
key={event.id}
event={event}
dayWidth={dayWidth}
isStart={isSameDay(date, event.start)}
isEnd={isSameDay(date, event.end)}
onPress={() => onPress(event.start)}
/>
))}
</View>
{/* Single-day events container */}
<View style={[styles.singleDayContainer, {marginTop: multiDayEventsHeight}]}>
{visibleSingleDayEvents.map(event => (
<Event
key={event.id}
event={event}
onPress={() => onPress(event.start)}
/>
))}
{totalHiddenEvents > 0 && (
<Text style={styles.moreEvents}>
{totalHiddenEvents} More
</Text>
)}
</View>
</TouchableOpacity>
</View>
);
});
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const {data: rawEvents} = useGetEvents();
const setSelectedDate = useSetAtom(selectedDateAtom);
const setMode = useSetAtom(modeAtom);
const {profileData} = useAuthContext();
const scrollViewRef = useRef<FlashList<any>>(null);
const isTablet = Device.deviceType === Device.DeviceType.TABLET;
const screenWidth = isTablet ? Dimensions.get('window').width * 0.89 : Dimensions.get('window').width;
const screenHeight = isTablet ? Dimensions.get('window').height * 0.89 : Dimensions.get('window').height;
const dayWidth = screenWidth / 7;
const centerMonth = useRef(new Date());
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const onDayPress = useCallback(
(date: Date) => {
date && setSelectedDate(date);
setTimeout(() => {
setMode("day");
}, 100)
},
[setSelectedDate, setMode]
);
const getMonthData = useCallback((date: Date) => {
const start = startOfMonth(date);
const end = endOfMonth(date);
const days = eachDayOfInterval({start, end});
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));
}
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 months = [];
for (let i = -CENTER_MONTH_INDEX; i <= CENTER_MONTH_INDEX; i++) {
const monthDate = addMonths(centerMonth.current, i);
months.push({
date: monthDate,
days: getMonthData(monthDate)
});
}
return months;
}, [getMonthData, rawEvents]);
const processedEvents = useMemo(() => {
if (!rawEvents?.length) return {
eventMap: new Map(),
multiDayEvents: []
};
const eventMap = new Map();
const multiDayEvents: CalendarEvent[] = [];
rawEvents.forEach((event) => {
if (!event?.start || !event?.end) return;
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;
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (duration > 1) {
multiDayEvents.push({
...event,
isMultiDay: true,
start: startDate,
end: endDate
});
} else {
const dateStr = format(startDate, 'yyyy-MM-dd');
const existing = eventMap.get(dateStr) || [];
eventMap.set(dateStr, [...existing, {...event, start: startDate, end: endDate}]);
}
});
multiDayEvents.sort((a, b) => {
if (!a.start || !b.start || !a.end || !b.end) return 0;
const durationA = a.end.getTime() - a.start.getTime();
const durationB = b.end.getTime() - b.start.getTime();
return durationB - durationA;
});
return {eventMap, multiDayEvents};
}, [rawEvents]);
const getMultiDayEventsForDay = useCallback((date: Date) => {
return processedEvents.multiDayEvents.filter(event => {
if (!event.start || !event.end) return false;
return isWithinInterval(date, {
start: event.start,
end: event.end
});
});
}, [processedEvents.multiDayEvents]);
const getEventsForDay = useCallback((date: Date) => {
const dateStr = format(date, 'yyyy-MM-dd');
return processedEvents.eventMap.get(dateStr) || [];
}, [processedEvents.eventMap]);
const sortedDaysOfWeek = useMemo(() => {
const days = [...DAYS_OF_WEEK];
return days.slice(weekStartsOn).concat(days.slice(0, weekStartsOn));
}, [weekStartsOn]);
const renderMonth = useCallback(({item}: { item: MonthData }) => (
<Month
date={item.date}
days={item.days}
getEventsForDay={getEventsForDay}
getMultiDayEventsForDay={getMultiDayEventsForDay}
dayWidth={dayWidth}
onPress={onDayPress}
screenWidth={screenWidth}
sortedDaysOfWeek={sortedDaysOfWeek}
/>
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
return (
<View style={styles.container}>
<CalendarController
scrollViewRef={scrollViewRef}
centerMonthIndex={CENTER_MONTH_INDEX}
/>
<FlashList
ref={scrollViewRef}
data={monthsToRender}
renderItem={renderMonth}
keyExtractor={keyExtractor}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={CENTER_MONTH_INDEX}
removeClippedSubviews={true}
estimatedItemSize={screenWidth}
estimatedListSize={{width: screenWidth, height: screenHeight * 0.9}}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 10,
}}
/>
</View>
);
};
type MonthData = {
date: Date;
days: Date[];
};
const keyExtractor = (item: MonthData, index: number) => `month-${index}`;
const Month = React.memo(({
date,
days,
getEventsForDay,
getMultiDayEventsForDay,
dayWidth,
onPress,
screenWidth,
sortedDaysOfWeek
}: {
date: Date;
days: Date[];
getEventsForDay: (date: Date) => CalendarEvent[];
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
screenWidth: number;
sortedDaysOfWeek: string[];
}) => {
const weeks = useMemo(() => {
const result = [];
for (let i = 0; i < days.length; i += 7) {
result.push(days.slice(i, i + 7));
}
return result;
}, [days]);
const eventPositions = useMemo(() => {
const positions = new Map<string, number>();
const weekTracking = new Map<number, Set<string>>();
weeks.forEach((week, weekIndex) => {
const activeEvents = new Set<string>();
week.forEach(day => {
const events = getMultiDayEventsForDay(day);
events.forEach(event => {
activeEvents.add(event.id);
});
});
weekTracking.set(weekIndex, activeEvents);
activeEvents.forEach(eventId => {
if (!positions.has(eventId)) {
const prevWeekEvents = weekIndex > 0 ? weekTracking.get(weekIndex - 1) : new Set<string>();
const usedPositions = new Set<number>();
if (prevWeekEvents) {
prevWeekEvents.forEach(prevEventId => {
if (activeEvents.has(prevEventId)) {
usedPositions.add(positions.get(prevEventId) || 0);
}
});
}
let position = 0;
while (usedPositions.has(position)) {
position++;
}
positions.set(eventId, position);
}
});
});
return positions;
}, [weeks, getMultiDayEventsForDay]);
return (
<View style={[styles.scrollView, {width: screenWidth}]}>
<View style={styles.monthHeader}>
<Text style={styles.monthText}>{format(date, 'MMMM yyyy')}</Text>
<View style={styles.weekDayRow}>
{sortedDaysOfWeek.map((day, index) => (
<Text key={index} style={styles.weekDayText}>{day}</Text>
))}
</View>
</View>
<View style={styles.daysGrid}>
{weeks.map((week, weekIndex) => (
<React.Fragment key={weekIndex}>
{week.map((date, dayIndex) => {
const multiDayEvents = getMultiDayEventsForDay(date).map(event => ({
...event,
weekPosition: eventPositions.get(event.id) || 0
}));
return (
<Day
key={`${weekIndex}-${dayIndex}`}
date={date}
events={getEventsForDay(date)}
multiDayEvents={multiDayEvents}
dayWidth={dayWidth}
onPress={onPress}
/>
);
})}
</React.Fragment>
))}
</View>
</View>
);
});
const HEADER_HEIGHT = 40;
const styles = StyleSheet.create({
multiDayContainer: {
position: 'absolute',
top: 29,
left: 0,
right: 0,
bottom: 0,
zIndex: 1,
},
dayContent: {
flex: 1,
padding: 4, // Move padding here instead
},
eventsContainer: {
flex: 1,
marginTop: 2,
position: 'relative',
},
event: {
borderRadius: 4,
padding: 1,
marginVertical: 1,
height: 14,
},
eventText: {
fontSize: 10,
color: '#fff',
fontWeight: '500',
},
day: {
height: '14%',
padding: 0,
borderWidth: 0.5,
borderColor: '#eee',
position: 'relative',
overflow: 'visible',
},
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'
},
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',
},
moreEvents: {
fontSize: 10,
color: '#666',
textAlign: 'center',
},
monthHeader: {
paddingVertical: 12,
},
monthText: {
fontSize: 16,
fontWeight: '600',
color: '#333',
textAlign: 'center',
marginBottom: 8,
},
weekDayRow: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingHorizontal: 0,
},
});
export default MonthCalendar;