Files
cally/components/pages/calendar/MonthCalendar.tsx
Milan Paunovic 580104d052 Month cal changes
2025-01-23 02:16:07 +01:00

596 lines
19 KiB
TypeScript

import React, {useCallback, useMemo, useRef, useState} from 'react';
import {
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
NativeScrollEvent,
NativeSyntheticEvent,
} from 'react-native';
import {
addDays,
eachDayOfInterval,
endOfMonth,
format,
isSameDay,
isSameMonth,
startOfMonth,
addMonths, startOfWeek, isWithinInterval,
} 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 {FlashList} from "@shopify/flash-list";
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 CENTER_MONTH_INDEX = 24;
const DayHeader = React.memo(({day, width}: { day: string; width: string }) => (
<View style={[styles.weekDay, {width}]}>
<Text style={styles.weekDayText}>{day}</Text>
</View>
));
const Event = React.memo(({event, onPress}: { event: CalendarEvent; onPress: () => void }) => (
<TouchableOpacity
style={[styles.event, {backgroundColor: event.color || '#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.color || '#6200ee',
borderRadius: isStart || isEnd ? 4 : 0,
borderTopLeftRadius: isStart ? 4 : 0,
borderBottomLeftRadius: isStart ? 4 : 0,
borderTopRightRadius: isEnd ? 4 : 0,
borderBottomRightRadius: isEnd ? 4 : 0,
padding: 1,
zIndex: 1,
left: isStart ? 2 : 0,
right: isEnd ? 2 : 0,
top: event.weekPosition ? event.weekPosition * 20 : 0,
marginRight: !isEnd ? -1 : 0,
};
return (
<TouchableOpacity style={style} onPress={onPress}>
{isStart && (
<Text style={[styles.eventText]} numberOfLines={1}>
{event.title}
</Text>
)}
</TouchableOpacity>
);
});
const Day = React.memo(({
date,
selectedDate,
events,
multiDayEvents,
dayWidth,
onPress
}: {
date: Date;
selectedDate: Date;
events: CalendarEvent[];
multiDayEvents: CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
}) => {
const isCurrentMonth = isSameMonth(date, selectedDate);
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;
return (
<View style={[styles.day, { width: dayWidth }]}>
<TouchableOpacity
style={styles.dayContent}
onPress={() => onPress(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}>
{/* Multi-day events first */}
{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)}
/>
))}
{/* Single-day events */}
{visibleSingleDayEvents.map(event => (
<Event
key={event.id}
event={event}
onPress={() => onPress(event.start)}
/>
))}
{/* Show total number of hidden events */}
{totalHiddenEvents > 0 && (
<Text style={styles.moreEvents}>
{totalHiddenEvents} More
</Text>
)}
</View>
</TouchableOpacity>
</View>
);
});
const findFirstAvailablePosition = (usedPositions: number[]): number => {
let position = 0;
while (usedPositions.includes(position)) {
position++;
}
return position;
};
export const MonthCalendar: React.FC<CustomMonthCalendarProps> = () => {
const {data: rawEvents} = useGetEvents();
const [selectedDate, setSelectedDate] = useAtom(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 - 32) / 7;
const centerMonth = useRef(selectedDate);
const weekStartsOn = profileData?.firstDayOfWeek === "Sundays" ? 0 : 1;
const events = useMemo(() => {
if (!rawEvents?.length) return new Map();
if (!selectedDate) return new Map();
const eventMap = new Map();
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 dateStr = format(startDate, 'yyyy-MM-dd');
const existing = eventMap.get(dateStr) || [];
eventMap.set(dateStr, [...existing, event]);
});
return eventMap;
}, [rawEvents, selectedDate]);
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]);
const processedEvents = useMemo(() => {
if (!rawEvents?.length || !selectedDate) 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, selectedDate]);
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}
selectedDate={selectedDate}
getEventsForDay={getEventsForDay}
getMultiDayEventsForDay={getMultiDayEventsForDay}
dayWidth={dayWidth}
onPress={onDayPress}
screenWidth={screenWidth}
/>
), [getEventsForDay, getMultiDayEventsForDay, dayWidth, onDayPress, screenWidth, weekStartsOn]);
const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const currentMonthIndex = Math.round(event.nativeEvent.contentOffset.x / screenWidth);
const currentMonth = monthsToRender[currentMonthIndex];
if (currentMonth) {
setSelectedDate(currentMonth.date);
centerMonth.current = currentMonth.date;
}
}, [screenWidth, setSelectedDate, monthsToRender]);
return (
<View style={styles.container}>
<View style={styles.header}>
{sortedDaysOfWeek.map((day, index) => (
<DayHeader key={index} day={day} width={`${100 / 7}%`}/>
))}
</View>
<FlashList
ref={scrollViewRef}
data={monthsToRender}
renderItem={renderMonth}
keyExtractor={keyExtractor}
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
initialScrollIndex={CENTER_MONTH_INDEX}
onMomentumScrollEnd={onMomentumScrollEnd}
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,
selectedDate,
getEventsForDay,
getMultiDayEventsForDay,
dayWidth,
onPress,
screenWidth
}: {
date: Date;
days: Date[];
selectedDate: Date;
getEventsForDay: (date: Date) => CalendarEvent[];
getMultiDayEventsForDay: (date: Date) => CalendarEvent[];
dayWidth: number;
onPress: (date: Date) => void;
screenWidth: number;
}) => {
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.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}
selectedDate={selectedDate}
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: 0,
left: 0,
right: 0,
zIndex: 1,
},
dayContent: {
flex: 1,
},
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: 4,
borderWidth: 0.5,
borderColor: '#eee',
position: 'relative',
},
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',
},
});